How to buildOctober 11, 20268 min read
DatabasesStep 12 of 12

PostgreSQL or MySQL: practical differences

In short

PostgreSQL and MySQL are mature relational databases with transactions and replication, and either works for a typical web application. The differences show up in the details: PostgreSQL defaults to Read Committed while InnoDB in MySQL defaults to Repeatable Read; MySQL compares strings case-insensitively and cannot roll back CREATE TABLE; PostgreSQL has richer types, jsonb with GIN indexes and an extension system. Choose PostgreSQL for a complex data model, in-database analytics and extensions such as PostGIS or pgvector; choose MySQL when your stack requires it (WordPress, for example) or your team and hosting already run it.

ComparisonLevel: IntermediateMySQL

After this you will be able to

  • name the practical differences between PostgreSQL and MySQL: string comparison, types, DDL in transactions, RETURNING, JSON and indexes, extensions
  • explain how Read Committed in PostgreSQL differs from Repeatable Read in MySQL and demonstrate it with two sessions
  • predict which SQL behaves differently in the two databases and rewrite it portably
  • choose a database for a project with the “when to use which” table, taking hosting and ecosystem into account

Verified with

PostgreSQL 17.11MySQL 8.4.11 LTS

Checked against docs: October 11, 2026

Contents
  1. 01Common ground and versions
  2. 02One script, two results
  3. 03Transactions: Read Committed vs Repeatable Read
  4. 04JSON, types and extensions
  5. 05Replication, hosting and ecosystem
  6. 06When to use which

Common ground and versions

Both are relational: tables, SQL, foreign keys, transactions, replication, managed services from the major clouds. So “which is better” has no answer without context. It is more useful to know where they behave differently: these are the spots that break a migration and surprise a developer used to the other database.

Versions at the time of checking. Each PostgreSQL major version is supported for five years: the current one is 18, and 17, used for the examples, is supported until November 2029. MySQL has two release tracks: LTS (8.4) gets fixes only, with no feature removals, and follows the Oracle Lifetime Support policy — five years of premier and three years of extended support; Innovation releases bring new features and are supported until the next release. The MySQL documentation already describes the next LTS line, 9.7, and upgrades between adjacent LTS lines without skipping one.

Default isolation

PostgreSQL 17
Read Committed
MySQL 8.4 (InnoDB)
Repeatable Read

Default string comparison

PostgreSQL 17
Case-sensitive
MySQL 8.4 (InnoDB)
Case- and accent-insensitive (utf8mb4_0900_ai_ci)

CREATE TABLE inside a transaction

PostgreSQL 17
Rolled back with the transaction
MySQL 8.4 (InnoDB)
Implicit COMMIT, cannot be rolled back

Get the inserted row back

PostgreSQL 17
INSERT … RETURNING
MySQL 8.4 (InnoDB)
No RETURNING; LAST_INSERT_ID() for auto-increment

7 / 2

PostgreSQL 17
3 (integer division)
MySQL 8.4 (InnoDB)
3.5000

||

PostgreSQL 17
String concatenation
MySQL 8.4 (InnoDB)
Logical OR (deprecated synonym)

boolean

PostgreSQL 17
A real type: t / f
MySQL 8.4 (InnoDB)
Synonym for TINYINT(1): 1 / 0

JSON

PostgreSQL 17
jsonb + a GIN index on the whole document
MySQL 8.4 (InnoDB)
JSON + an index on a generated column or a multi-valued index

Extensions

PostgreSQL 17
CREATE EXTENSION: contrib modules, PostGIS, pgvector
MySQL 8.4 (InnoDB)
Features are built into the server and depend on the version

One script, two results

Let us run the same file in both databases. Both servers are throwaway containers with --rm: nothing is left after they stop.

bash
docker run -d --rm --name kb-pg-pgvsmy -e POSTGRES_PASSWORD=test postgres:17-alpine
docker run -d --rm --name kb-mysql-pgvsmy -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=kb mysql:8.4
docker cp compare.sql kb-pg-pgvsmy:/tmp/
Two throwaway servers; the file is copied into the PostgreSQL container, MySQL reads it from standard input
compare.sql
-- compare.sql — one script for PostgreSQL 17 and MySQL 8.4
CREATE TABLE customers (
  id     integer PRIMARY KEY,
  email  varchar(50) NOT NULL UNIQUE,
  name   varchar(10) NOT NULL,
  active boolean NOT NULL DEFAULT true
);

INSERT INTO customers (id, email, name) VALUES (1, 'aigerim@example.com', 'Aigerim');

-- 1. The same email in a different case
INSERT INTO customers (id, email, name) VALUES (2, 'Aigerim@Example.com', 'Aigerim');
SELECT 'Almaty' = 'ALMATY' AS same_text;

-- 2. A value longer than the column
INSERT INTO customers (id, email, name) VALUES (3, 'kon@example.com', 'Konstantin Ivanov');

-- 3. Integer division and the || operator
SELECT 7 / 2 AS division;
SELECT 'Inter' || 'LAB' AS joined;

-- 4. What a boolean looks like
SELECT id, active FROM customers ORDER BY id;

-- 5. Can you roll back CREATE TABLE?
BEGIN;
CREATE TABLE draft (id integer);
ROLLBACK;
SELECT count(*) AS draft_exists FROM information_schema.tables WHERE table_name = 'draft';

-- 6. Get the new row back in the same statement
INSERT INTO customers (id, email, name) VALUES (4, 'timur@example.com', 'Timur') RETURNING id, active;
Six checks: case, length, division, ||, boolean, DDL in a transaction and RETURNING
terminal
$ docker exec kb-pg-pgvsmy psql -U postgres -f /tmp/compare.sql
CREATE TABLE
INSERT 0 1
INSERT 0 1
 same_text 
-----------
 f
(1 row)

psql:/tmp/compare.sql:16: ERROR:  value too long for type character varying(10)
 division 
----------
        3
(1 row)

  joined  
----------
 InterLAB
(1 row)

 id | active 
----+--------
  1 | t
  2 | t
(2 rows)

BEGIN
CREATE TABLE
ROLLBACK
 draft_exists 
--------------
            0
(1 row)

 id | active 
----+--------
  4 | t
(1 row)

INSERT 0 1
Real PostgreSQL 17.11 output: psql keeps going after an error
terminal
$ docker exec -i -e MYSQL_PWD=test kb-mysql-pgvsmy mysql -uroot --force --unbuffered --show-warnings -t kb < compare.sql
ERROR 1062 (23000) at line 12: Duplicate entry 'Aigerim@Example.com' for key 'customers.email'
+-----------+
| same_text |
+-----------+
|         1 |
+-----------+
ERROR 1406 (22001) at line 16: Data too long for column 'name' at row 1
+----------+
| division |
+----------+
|   3.5000 |
+----------+
+--------+
| joined |
+--------+
|      0 |
+--------+
Warning (Code 1287): '|| as a synonym for OR' is deprecated and will be removed in a future release. Please use OR instead
Warning (Code 1292): Truncated incorrect DOUBLE value: 'Inter'
Warning (Code 1292): Truncated incorrect DOUBLE value: 'LAB'
+----+--------+
| id | active |
+----+--------+
|  1 |      1 |
+----+--------+
+--------------+
| draft_exists |
+--------------+
|            1 |
+--------------+
ERROR 1064 (42000) at line 32: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'RETURNING id, active' at line 1
Real MySQL 8.4.11 output: --force continues after errors, --show-warnings prints warnings

Email in a different case

PostgreSQL
Insert succeeded: two “different” rows
MySQL
Duplicate entry: to the collation it is the same email
What to do
In PostgreSQL, a unique index on lower(email); in MySQL, remember that é = e

Value longer than the column

PostgreSQL
Error
MySQL
Error — thanks to STRICT_TRANS_TABLES in the default sql_mode
What to do
Keep strict mode on: without it MySQL truncates the value with only a warning

7 / 2

PostgreSQL
3: integer division drops the fraction
MySQL
3.5000: MySQL has DIV for integer division
What to do
Cast explicitly: CAST(7 AS decimal(10, 2)) / 2

'Inter' || 'LAB'

PostgreSQL
InterLAB
MySQL
0 and three warnings
What to do
CONCAT() works in both

boolean

PostgreSQL
t
MySQL
1
What to do
Drivers map both to the language's boolean, but raw exports show the difference

CREATE TABLE + ROLLBACK

PostgreSQL
No table
MySQL
The table remains
What to do
In MySQL a failed migration can apply partially — plan the rollback separately

RETURNING

PostgreSQL
Returned id and active
MySQL
Syntax error
What to do
In MySQL, LAST_INSERT_ID() after the insert

Transactions: Read Committed vs Repeatable Read

Both databases accept all four isolation levels from the SQL standard (in PostgreSQL, Read Uncommitted behaves like Read Committed), but they pick different defaults.

terminal
$ docker exec kb-pg-pgvsmy psql -U postgres -c 'SHOW transaction_isolation'
 transaction_isolation 
-----------------------
 read committed
(1 row)

$ docker exec -e MYSQL_PWD=test kb-mysql-pgvsmy mysql -uroot -t -e 'SELECT @@transaction_isolation'
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ         |
+-------------------------+
Real output: the default isolation level

In Read Committed each statement sees data committed before it started, so two identical SELECTs in one transaction can return different results. In InnoDB's Repeatable Read all plain reads in a transaction see the snapshot taken by the first read. Let us check with two sessions: A reads, B inserts a row and commits, A reads again.

isolation.sh
#!/usr/bin/env bash
# isolation.sh — session A reads twice inside one transaction,
# session B inserts and commits a row between those reads.
# Usage: ./isolation.sh pg | ./isolation.sh mysql
set -eu

if [ "$1" = "pg" ]; then
  client() { docker exec -i kb-pg-pgvsmy psql -U postgres -qtA; }
else
  client() { docker exec -i -e MYSQL_PWD=test kb-mysql-pgvsmy mysql -uroot -N --unbuffered kb; }
fi

{
  echo "BEGIN;"
  echo "SELECT 'A, first read:', count(*) FROM customers WHERE id >= 5;"
  sleep 2   # session B commits during this pause
  echo "SELECT 'A, second read:', count(*) FROM customers WHERE id >= 5;"
  echo "COMMIT;"
} | client &

sleep 1
echo "INSERT INTO customers (id, email, name) VALUES (5, 'dana@example.com', 'Dana');" | client
echo "B: inserted id 5 and committed"
wait
Two sessions: A holds a transaction, B inserts a row in the pause between reads
terminal
$ bash isolation.sh pg
A, first read:|0
B: inserted id 5 and committed
A, second read:|1

$ bash isolation.sh mysql
A, first read:	0
B: inserted id 5 and committed
A, second read:	0
Real output: PostgreSQL sees the new row on the second read, MySQL does not
  • PostgreSQL in Read Committed: a report built from several queries in one transaction may combine numbers from different moments. If you need a consistent snapshot, use BEGIN ISOLATION LEVEL REPEATABLE READ.
  • In Repeatable Read, PostgreSQL returns could not serialize access due to concurrent update when you try to change a row that another transaction has already changed and committed; the documentation says to retry the whole transaction. Code that runs above Read Committed must be able to retry transactions.
  • MySQL in Repeatable Read: the snapshot applies to plain SELECTs but not to UPDATE and DELETE — they can change rows another transaction has just committed, even though SELECTs in the same transaction cannot see them. Locking reads (SELECT … FOR UPDATE) and changes take locks, including gap locks. The documentation advises against mixing locking and plain reads in one such transaction.

JSON, types and extensions

Both databases store JSON in a binary format and validate it on write. The difference is in search and indexing. In PostgreSQL a GIN index covers the whole jsonb document and speeds up the containment operator @>.

json_pg.sql
-- json_pg.sql — PostgreSQL: jsonb, the containment operator and a GIN index
CREATE TABLE products (
  id    integer PRIMARY KEY,
  attrs jsonb NOT NULL
);

INSERT INTO products VALUES
  (1, '{"name": "Latte", "tags": ["coffee", "milk"], "volume_ml": 350}'),
  (2, '{"name": "Espresso", "tags": ["coffee"], "volume_ml": 40}'),
  (3, '{"name": "Matcha", "tags": ["tea", "milk"]}');

CREATE INDEX products_attrs_gin ON products USING GIN (attrs jsonb_path_ops);

SELECT id, attrs ->> 'name' AS name
FROM products
WHERE attrs @> '{"tags": ["milk"]}'
ORDER BY id;

SET enable_seqscan = off;  -- three rows: force the planner to show the index
EXPLAIN (COSTS OFF) SELECT id FROM products WHERE attrs @> '{"tags": ["milk"]}';
jsonb, the containment operator and a GIN index
terminal
$ docker exec kb-pg-pgvsmy psql -U postgres -f /tmp/json_pg.sql
CREATE TABLE
INSERT 0 3
CREATE INDEX
 id |  name  
----+--------
  1 | Latte
  3 | Matcha
(2 rows)

SET
                         QUERY PLAN                         
------------------------------------------------------------
 Bitmap Heap Scan on products
   Recheck Cond: (attrs @> '{"tags": ["milk"]}'::jsonb)
   ->  Bitmap Index Scan on products_attrs_gin
         Index Cond: (attrs @> '{"tags": ["milk"]}'::jsonb)
(4 rows)
Real PostgreSQL output: with three rows, sequential scans are switched off to show the index

In MySQL a JSON column is not indexed directly: you index a generated column that extracts a value by path, or build a multi-valued index on an array with CAST(… AS … ARRAY). The optimizer uses such an index for MEMBER OF(), JSON_CONTAINS() and JSON_OVERLAPS().

json_mysql.sql
-- json_mysql.sql — MySQL: JSON, MEMBER OF and a multi-valued index
CREATE TABLE products (
  id    integer PRIMARY KEY,
  attrs json NOT NULL
);

INSERT INTO products VALUES
  (1, '{"name": "Latte", "tags": ["coffee", "milk"], "volume_ml": 350}'),
  (2, '{"name": "Espresso", "tags": ["coffee"], "volume_ml": 40}'),
  (3, '{"name": "Matcha", "tags": ["tea", "milk"]}');

-- A JSON column cannot be indexed directly...
CREATE INDEX products_attrs_idx ON products (attrs);

-- ...so index the array values instead (a multi-valued index)
CREATE INDEX products_tags_idx ON products ((CAST(attrs -> '$.tags' AS CHAR(20) ARRAY)));

SELECT id, attrs ->> '$.name' AS name
FROM products
WHERE 'milk' MEMBER OF (attrs -> '$.tags')
ORDER BY id;

EXPLAIN FORMAT=TREE SELECT id FROM products WHERE 'milk' MEMBER OF (attrs -> '$.tags');
JSON, MEMBER OF and a multi-valued index
terminal
$ docker exec -i -e MYSQL_PWD=test kb-mysql-pgvsmy mysql -uroot --force --unbuffered -r kb < json_mysql.sql
ERROR 3152 (42000) at line 13: JSON column 'attrs' supports indexing only via generated columns on a specified JSON path.
id	name
1	Latte
3	Matcha
EXPLAIN
-> Filter: json'"milk"' member of (cast(json_extract(attrs,_latin1'$.tags') as char(20) array))  (cost=0.7 rows=2)
    -> Index lookup on products using products_tags_idx (cast(json_extract(attrs,_latin1'$.tags') as char(20) array)=json'"milk"')  (cost=0.7 rows=2)
Real MySQL 8.4 output: the direct index is rejected, the multi-valued one is used

PostgreSQL has more built-in types: uuid, network inet and cidr, intervals, arrays, ranges, tsvector for full-text search, and custom types via CREATE TYPE. In MySQL BOOLEAN is a synonym for TINYINT(1), but the server has built-in spatial types following the OpenGIS model with SPATIAL indexes in InnoDB, plus full-text indexes.

The defining PostgreSQL feature is extensions. Bundled modules (contrib) are enabled with CREATE EXTENSION in the database that needs them; third-party projects such as PostGIS for geodata and pgvector for vector search are installed on the server separately.

ext_pg.sql
-- ext_pg.sql — extensions in the official postgres:17-alpine image
SELECT count(*) AS available FROM pg_available_extensions;

CREATE EXTENSION pg_trgm;                        -- trigram similarity, ships with PostgreSQL
SELECT similarity('Almaty', 'Almata') AS score;

CREATE EXTENSION vector;                         -- pgvector is a separate project
Extensions in the official postgres:17-alpine image
terminal
$ docker exec kb-pg-pgvsmy psql -U postgres -f /tmp/ext_pg.sql
 available 
-----------
        59
(1 row)

CREATE EXTENSION
   score   
-----------
 0.5555556
(1 row)

psql:/tmp/ext_pg.sql:7: ERROR:  extension "vector" is not available
DETAIL:  Could not open extension control file "/usr/local/share/postgresql/extension/vector.control": No such file or directory.
HINT:  The extension must first be installed on the system where PostgreSQL is running.
Real output: pg_trgm is enabled, pgvector is not in the image

Hence a practical rule: if your project depends on an extension, check that your hosting has it — managed services only offer the list the provider supports, and in Docker you need an image with the extension installed.

Replication, hosting and ecosystem

Physical replication

PostgreSQL
WAL streaming to standby servers; a hot standby serves read-only queries
MySQL
Replication from the binary log (binlog) from a source to replicas, asynchronous by default

Logical and other modes

PostgreSQL
Logical replication of selected tables by replica identity (usually the primary key); synchronous replication as an option
MySQL
GTIDs instead of log positions, semisynchronous replication, Group Replication and InnoDB Cluster

Managed services

PostgreSQL
Amazon RDS, Google Cloud SQL and others
MySQL
Amazon RDS, Google Cloud SQL and others

Ecosystem

PostgreSQL
Extensions (PostGIS, pgvector), complex queries and in-database analytics
MySQL
The traditional PHP stack; WordPress requires MySQL 8.0+ or MariaDB 10.11+

Hosting is hardly a differentiator: Amazon RDS and Google Cloud SQL offer both. The differences are in the details — which versions and extensions are available, how replicas are configured — so check them in your provider's documentation rather than by the database name alone.

When to use which

A new product with no hard stack constraints

Choice
PostgreSQL
Why
Strict types, transactional migrations, jsonb and extensions leave room to grow

Geodata, full-text or vector search in the main database

Choice
PostgreSQL
Why
PostGIS, tsvector, pgvector — without a separate service

Complex reports and in-database analytics

Choice
PostgreSQL
Why
Rich types and functions, Read Committed with an explicit switch to Repeatable Read for reports

WordPress and other systems built for MySQL

Choice
MySQL (or MariaDB)
Why
It is the system's own requirement; anything else is unsupported

The team and hosting already run MySQL with experience and monitoring

Choice
MySQL
Why
Familiar operations matter more than differences the project does not need

Migrating an existing application

Choice
The database it uses now
Why
Switching touches collations, isolation, DDL and syntax — it is a project of its own
  • Which database does the project's platform or CMS require?
  • Do you need extensions (PostGIS, pgvector), and does your hosting offer them?
  • Which isolation level does the code expect, and can it retry transactions?
  • Should unique text fields be case-sensitive?
  • How is a failed migration rolled back?
How this was verified6 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.