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.
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
Know beforehand
Verified with
PostgreSQL 17.11MySQL 8.4.11 LTS
Checked against docs: October 11, 2026
Contents
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.
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/-- 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;||, boolean, DDL in a transaction and RETURNING$ 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$ 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--force continues after errors, --show-warnings prints warningsEmail 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_TABLESin the defaultsql_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 hasDIVfor integer division- What to do
- Cast explicitly:
CAST(7 AS decimal(10, 2)) / 2
'Inter' || 'LAB'
- PostgreSQL
InterLAB- MySQL
0and 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
idandactive - 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.
$ 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 |
+-------------------------+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.
#!/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$ 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- 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 updatewhen 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 toUPDATEandDELETE— they can change rows another transaction has just committed, even thoughSELECTs 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 — 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"]}';$ 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)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 — 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');$ 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)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 — 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$ 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.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,
jsonband 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.