Database NotesRohit’s interview study guide
Chapter 14

MySQL vs PostgreSQL

The two most popular open-source relational databases side by side — MySQL 9.7 and PostgreSQL 18, each run for real on the same data — so you can explain where they differ and choose between them.

7 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Two philosophies

Added

MySQL and PostgreSQL both speak SQL, both store rows in B-tree-indexed tables with MVCC and write-ahead logs, and both run most of the web. They grew from different priorities, and those priorities still show in everyday behaviour:

  • MySQL started (1995) as a fast, simple database for web applications. It favoured speed and ease of use, was lenient by default (bad values became warnings, not errors), and put replication first. Its architecture separates the SQL server from pluggable storage engines — today almost always InnoDB.
  • PostgreSQL grew out of the Berkeley POSTGRES research project (1986) and aims for correctness, standards and extensibility: strict typing, transactional everything (including DDL), and a type and index system you can extend with your own types, operators, index methods and languages.

A rough mental model: MySQL is a well-tuned appliance — it does a known set of things very fast and is easy to operate; PostgreSQL is a workshop — more tools, stricter rules, and you can build new tools into it. Since MySQL 5.7/8.0 turned strict mode on by default and added CTEs, window functions and JSON, the gap in everyday SQL is much smaller than old blog posts suggest, but it hasn't closed.

The quickest way to feel the difference is to give both the same slightly sloppy SQL:

MySQL 9.7

SQL
SELECT 7 / 2 AS division;
SELECT '5' + 3 AS text_plus_number;
SELECT 'abc' + 1 AS junk_plus_number;
SELECT 'a' || 'b' AS pipes;
SELECT 1 / 0 AS by_zero;
SELECT 'Rohit' = 'rohit' AS same_name;
Result
+----------+
| division |
+----------+
|   3.5000 |
+----------+
+------------------+
| text_plus_number |
+------------------+
|                8 |
+------------------+
+------------------+
| junk_plus_number |
+------------------+
|                1 |
+------------------+
Warning (Code 1292): Truncated incorrect DOUBLE value: 'abc'
+-------+
| pipes |
+-------+
|     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: 'a'
Warning (Code 1292): Truncated incorrect DOUBLE value: 'b'
+---------+
| by_zero |
+---------+
|    NULL |
+---------+
Warning (Code 1365): Division by 0
+-----------+
| same_name |
+-----------+
|         1 |
+-----------+

PostgreSQL 18

SQL
SELECT 7 / 2 AS division;
SELECT '5' + 3 AS text_plus_number;
SELECT 'abc' + 1 AS junk_plus_number;
SELECT 'a' || 'b' AS pipes;
SELECT 1 / 0 AS by_zero;
SELECT 'Rohit' = 'rohit' AS same_name;
Result
 division 
----------
        3
(1 row)

 text_plus_number 
------------------
                8
(1 row)

ERROR:  invalid input syntax for type integer: "abc"
LINE 1: SELECT 'abc' + 1 AS junk_plus_number;
               ^
 pipes 
-------
 ab
(1 row)

ERROR:  division by zero
 same_name 
-----------
 f
(1 row)
What’s happening
  1. 7 / 2: MySQL's / always returns a decimal, 3.5000. PostgreSQL follows the operand types — integer divided by integer is integer division — so 3. Write 7.0 / 2 (or 7::numeric / 2) in PostgreSQL when you want the fraction.
  2. '5' + 3 is 8 in both. In PostgreSQL that's because '5' is an untyped literal that gets resolved as an integer; a real text column plus a number would be an error.
  3. 'abc' + 1: MySQL converts 'abc' to a number, finds no digits, uses 0, returns 1 and leaves a warning most applications never read. PostgreSQL refuses: invalid input syntax for type integer. This is the philosophy in one line — MySQL tries to produce an answer, PostgreSQL refuses to guess.
  4. 'a' || 'b': standard SQL string concatenation, so PostgreSQL returns ab. In MySQL || is logical OR (unless sql_mode has PIPES_AS_CONCAT): both strings become 0, the result is 0, and MySQL now warns that this meaning is deprecated. Use CONCAT() for portable code.
  5. 1 / 0: NULL plus a warning in a MySQL SELECT (an error only in INSERT/UPDATE under strict mode); always an error in PostgreSQL.
  6. 'Rohit' = 'rohit': true in MySQL because the default collation utf8mb4_0900_ai_ci is case-insensitive; false in PostgreSQL, whose default collations are case-sensitive. This one changes query results, not just error handling.
AdvancedArchitecture: threads vs processes

MySQL serves each connection with a thread inside one mysqld process; PostgreSQL forks a separate operating-system process per connection, plus background processes:

MySQL 9.7

SQL
SELECT @@max_connections, @@thread_handling;
SELECT TYPE, COUNT(*) AS threads FROM performance_schema.threads GROUP BY TYPE;
SELECT THREAD_OS_ID, PROCESSLIST_ID, NAME FROM performance_schema.threads
WHERE PROCESSLIST_ID = CONNECTION_ID();
Result
+-------------------+---------------------------+
| @@max_connections | @@thread_handling         |
+-------------------+---------------------------+
|               151 | one-thread-per-connection |
+-------------------+---------------------------+
+------------+---------+
| TYPE       | threads |
+------------+---------+
| BACKGROUND |      41 |
| FOREGROUND |       5 |
+------------+---------+
+--------------+----------------+---------------------------+
| THREAD_OS_ID | PROCESSLIST_ID | NAME                      |
+--------------+----------------+---------------------------+
|         2328 |           1096 | thread/sql/one_connection |
+--------------+----------------+---------------------------+

PostgreSQL 18

SQL
SHOW max_connections;
SELECT backend_type, count(*) FROM pg_stat_activity
GROUP BY backend_type ORDER BY backend_type;
SELECT pid, backend_type FROM pg_stat_activity WHERE pid = pg_backend_pid();
Result
 max_connections 
-----------------
 100
(1 row)

         backend_type         | count 
------------------------------+-------
 autovacuum launcher          |     1
 background writer            |     1
 checkpointer                 |     1
 client backend               |     1
 io worker                    |     3
 logical replication launcher |     1
 walwriter                    |     1
(7 rows)

 pid |  backend_type  
-----+----------------
 751 | client backend
(1 row)
What’s happening
  1. MySQL: one-thread-per-connection, and this connection is the thread thread/sql/one_connection with OS thread id 2328, inside the single mysqld process. Background work (purge, page cleaners, I/O) is 41 more threads.
  2. PostgreSQL: this connection is a process with its own pid (751), a "client backend". The others are separate processes too — checkpointer, background writer, WAL writer, autovacuum launcher, and PostgreSQL 18's new io workers for asynchronous I/O (io_method = worker is the default in 18).
  3. A process per connection costs more memory and setup time than a thread, which is why PostgreSQL's default max_connections is 100 vs MySQL's 151, and why PostgreSQL deployments almost always put a pooler (PgBouncer, or the cloud provider's) in front. MySQL can also exhaust connections, but each one is cheaper. See Connection pooling.
  4. Processes give each session its own private memory and a simple, well-understood model that extensions rely on; moving PostgreSQL to threads has been discussed in the community for years but isn't part of 18. The cost is per-connection overhead, which the pooler absorbs.
AdvancedOld MySQL: the lenient defaults behind the reputation

Much of MySQL's "it accepts garbage" reputation comes from versions before 5.7, whose default sql_mode wasn't strict. Turn strict mode off on 9.7 and the old behaviour returns (see Numeric types: INT, DECIMAL and FLOAT for the clamped values it produced). Today's MySQL default is strict, has CTEs and window functions (8.0+), enforced CHECK constraints (8.0.16+), and rejects zero dates. When someone compares the two databases, ask which version they're thinking of.

#Data types: JSON vs JSONB, arrays and more

Added

The type systems are where the two differ most. PostgreSQL has many more built-in types and lets you add your own; MySQL keeps a smaller set and models the rest with JSON.

The headline comparison is JSON. MySQL has one JSON type, stored in a binary format. PostgreSQL has two:

  • json — stores the exact text you gave it (validated, but whitespace, key order and duplicate keys kept); every access re-parses it.
  • jsonb — stores a decomposed binary form: keys sorted, duplicates removed, whitespace dropped. Faster to query, indexable with GIN, and what you should use almost always.

Think of json as a photocopy of the letter you received, and jsonb as the same letter typed into a form: you can't get the original handwriting back, but you can search it.

MySQL 9.7

SQL
SELECT CAST('{"z": 1, "a": 2, "a": 3,   "mm": [1, 2]}' AS JSON) AS as_json;
Result
+--------------------------------+
| as_json                        |
+--------------------------------+
| {"a": 3, "z": 1, "mm": [1, 2]} |
+--------------------------------+

PostgreSQL 18

SQL
SELECT '{"z": 1, "a": 2, "a": 3,   "mm": [1, 2]}'::json  AS as_json,
       '{"z": 1, "a": 2, "a": 3,   "mm": [1, 2]}'::jsonb AS as_jsonb;
Result
                 as_json                  |            as_jsonb            
------------------------------------------+--------------------------------
 {"z": 1, "a": 2, "a": 3,   "mm": [1, 2]} | {"a": 3, "z": 1, "mm": [1, 2]}
(1 row)
What’s happening
  1. The same messy document goes in: keys out of order, a duplicate a, extra spaces.
  2. PostgreSQL json returns it byte for byte — duplicate a and all. It's only validated text.
  3. PostgreSQL jsonb and MySQL JSON both return the same normalised document: the last a wins (3), whitespace is gone, and keys are reordered (shorter keys first, then alphabetically: a, z, mm). They're both "parsed binary" formats.
  4. So MySQL's JSON is the counterpart of jsonb, not of json. The practical differences are in the operators and indexes around them (next cards): PostgreSQL can index a whole jsonb document with GIN; MySQL indexes chosen paths (see JSON columns).
  5. Use PostgreSQL json only when you must keep the exact original text (e.g. to verify a signature over it).
AdvancedQuerying and indexing jsonb

PostgreSQL's @> ("contains") operator asks "does this document contain that fragment?", and a GIN index answers it without reading every row. Here on 200,000 events, 200 of them tagged vip:

SQL
CREATE TABLE events (
  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  payload jsonb NOT NULL
);
INSERT INTO events (payload)
SELECT jsonb_build_object('type', (ARRAY['view','click','buy'])[1 + n % 3],
                          'customer_id', 1 + n % 6,
                          'tags', CASE WHEN n % 1000 = 0 THEN '["vip","promo"]'::jsonb ELSE '["regular"]'::jsonb END)
FROM generate_series(1, 200000) AS n;
CREATE INDEX idx_events_payload ON events USING gin (payload jsonb_path_ops);
ANALYZE events;
EXPLAIN (ANALYZE, COSTS OFF, TIMING ON, SUMMARY OFF)
SELECT count(*) FROM events WHERE payload @> '{"tags": ["vip"]}';
Result
                                             QUERY PLAN                                             
----------------------------------------------------------------------------------------------------
 Aggregate (actual time=0.179..0.179 rows=1.00 loops=1)
   Buffers: shared hit=202
   ->  Bitmap Heap Scan on events (actual time=0.024..0.173 rows=200.00 loops=1)
         Recheck Cond: (payload @> '{"tags": ["vip"]}'::jsonb)
         Heap Blocks: exact=200
         Buffers: shared hit=202
         ->  Bitmap Index Scan on idx_events_payload (actual time=0.011..0.011 rows=200.00 loops=1)
               Index Cond: (payload @> '{"tags": ["vip"]}'::jsonb)
               Index Searches: 1
               Buffers: shared hit=2
 Planning:
   Buffers: shared hit=27 read=4
(12 rows)
What’s happening
  1. generate_series creates 200,000 events; every thousandth one has tags: ["vip", "promo"].
  2. One GIN index covers the whole document: with jsonb_path_ops it indexes a hash of every path-and-value, so any containment query on any key can use it — no need to decide in advance which field to index.
  3. The plan: a Bitmap Index Scan finds the 200 matching rows in 0.011 ms (2 buffers), then a Bitmap Heap Scan fetches exactly those 200 pages and rechecks the condition. 0.18 ms in total.
  4. PostgreSQL 18's EXPLAIN ANALYZE includes buffer counts by default and the new "Index Searches" line.
  5. MySQL's closest equivalent is a multi-valued index on one chosen array path (CAST(payload->'$.tags' AS CHAR(20) ARRAY)) — fast too, but one path per index.
AdvancedArrays: a real type in PostgreSQL only

PostgreSQL has array types for every base type (int[], text[]…). MySQL has none; the closest is a JSON array:

SQL
-- PostgreSQL 18
CREATE TABLE product_details (
  product_id int PRIMARY KEY REFERENCES products(id),
  attrs      jsonb NOT NULL,
  tags       text[] NOT NULL DEFAULT '{}'
);
INSERT INTO product_details VALUES
  (1, '{"brand": "Lenovo", "ram_gb": 16, "ports": ["usb-c", "hdmi"]}', '{laptop,work}'),
  (2, '{"brand": "Apple",  "ram_gb": 16, "ports": ["usb-c"]}',         '{laptop}'),
  (3, '{"brand": "Google", "ram_gb": 12, "ports": ["usb-c"]}',         '{phone,travel}'),
  (8, '{"brand": "Sony",   "ports": ["usb-c", "3.5mm"], "anc": true}', '{travel}');
SELECT p.name, d.attrs->>'brand' AS brand, (d.attrs->>'ram_gb')::int AS ram_gb, d.tags
FROM products p JOIN product_details d ON d.product_id = p.id ORDER BY p.id;
SELECT product_id FROM product_details WHERE attrs @> '{"ports": ["hdmi"]}';
SELECT product_id FROM product_details WHERE 'travel' = ANY (tags) ORDER BY product_id;
SELECT product_id, unnest(tags) AS tag FROM product_details ORDER BY product_id, tag;
-- MySQL 9.7
CREATE TABLE arr_test (id INT PRIMARY KEY, tags TEXT[]);
Result
            name             | brand  | ram_gb |      tags      
-----------------------------+--------+--------+----------------
 ThinkBook 14                | Lenovo |     16 | {laptop,work}
 MacBook Air                 | Apple  |     16 | {laptop}
 Pixel 10                    | Google |     12 | {phone,travel}
 Noise-cancelling Headphones | Sony   |        | {travel}
(4 rows)

 product_id 
------------
          1
(1 row)

 product_id 
------------
          3
          8
(2 rows)

 product_id |  tag   
------------+--------
          1 | laptop
          1 | work
          2 | laptop
          3 | phone
          3 | travel
          8 | travel
(6 rows)

-- MySQL:
ERROR 1064 (42000): 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 '[])' at line 1
What’s happening
  1. tags text[] is a typed array column; '{laptop,work}' is PostgreSQL's array literal. Elements are typed — a text[] can't hold a number by accident.
  2. ->> works like MySQL's (attrs->>'brand'), but PostgreSQL paths are keys, not '$.brand' JSONPath strings, and the result is text — cast with ::int to get a number. A missing key gives NULL (the headphones' empty ram_gb).
  3. 'travel' = ANY (tags) tests membership; unnest(tags) turns an array into rows (MySQL needs JSON_TABLE for that).
  4. MySQL has no array syntax at all: TEXT[] is a syntax error. Use a JSON array with MEMBER OF, or — usually better in both databases — a junction table (see Relationships: one-to-one, one-to-many and many-to-many).
AdvancedBooleans, unsigned integers, UUIDs and time zones

A handful of types that trip people up when moving between the two:

SQL
-- PostgreSQL 18
CREATE TABLE flags (id int PRIMARY KEY, is_active boolean NOT NULL);
INSERT INTO flags VALUES (1, true), (2, 'yes'), (3, 'f');
INSERT INTO flags VALUES (4, 2);
SELECT * FROM flags ORDER BY id;
CREATE TABLE unsigned_test (n int unsigned);
SELECT uuidv7() AS v7_a, uuidv7() AS v7_b, uuidv4() AS v4;
SET TIME ZONE 'UTC';
SELECT '2025-06-01 12:00:00+05:30'::timestamptz AS stored_as_instant,
       '2025-06-01 12:00:00+05:30'::timestamp   AS offset_ignored;
Result
CREATE TABLE
INSERT 0 3
ERROR:  column "is_active" is of type boolean but expression is of type integer
LINE 1: INSERT INTO flags VALUES (4, 2);
                                     ^
HINT:  You will need to rewrite or cast the expression.
 id | is_active 
----+-----------
  1 | t
  2 | t
  3 | f
(3 rows)

ERROR:  syntax error at or near "unsigned"
LINE 1: CREATE TABLE unsigned_test (n int unsigned);
                                          ^
                 v7_a                 |                 v7_b                 |                  v4                  
--------------------------------------+--------------------------------------+--------------------------------------
 01a14571-c9af-7a04-992e-8f9b25cf639d | 01a14571-c9af-7a59-acdb-fd4fac4a9240 | d2d962ea-8493-4f1a-a6c6-2f80ea9cd517
(1 row)

SET
   stored_as_instant    |   offset_ignored    
------------------------+---------------------
 2025-06-01 06:30:00+00 | 2025-06-01 12:00:00
(1 row)
What’s happening
  1. boolean is a real type: true, 'yes' and 'f' are accepted, the integer 2 is not. In MySQL BOOLEAN is TINYINT(1) and 2 goes straight in (see ENUM, SET and BOOLEAN).
  2. No unsigned integers in PostgreSQL — int unsigned is a syntax error. Porting a MySQL INT UNSIGNED id means bigint (or int plus CHECK (n >= 0) if the range is enough).
  3. uuid is a native 16-byte type, and PostgreSQL 18 adds uuidv7(): both v7 values start with the same time prefix 01a14571-c9af-7… and sort in creation order, unlike the random uuidv4(). MySQL 9.7 has no UUID type (use BINARY(16)) and no v7 function (see Primary keys: AUTO_INCREMENT vs UUID).
  4. timestamptz ("timestamp with time zone") stores an instant in UTC and converts the +05:30 input: 06:30+00. Plain timestamp silently ignores the offset and keeps 12:00 — a classic porting bug. timestamptz is the PostgreSQL counterpart of MySQL's TIMESTAMP, without the 2038 limit (its range runs to the year 294276).
AdvancedRanges and temporal keys (PostgreSQL 18)

PostgreSQL has range types (int4range, daterange, tstzrange…) and, new in 18, primary and unique keys with WITHOUT OVERLAPS. "No double-booking" becomes a constraint:

SQL
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE room_bookings (
  room   int NOT NULL,
  during tstzrange NOT NULL,
  guest  text NOT NULL,
  PRIMARY KEY (room, during WITHOUT OVERLAPS)
);
INSERT INTO room_bookings VALUES (101, '[2025-06-01 14:00+00, 2025-06-03 11:00+00)', 'Rohit');
INSERT INTO room_bookings VALUES (101, '[2025-06-03 11:00+00, 2025-06-05 11:00+00)', 'Maya');
INSERT INTO room_bookings VALUES (101, '[2025-06-02 14:00+00, 2025-06-04 11:00+00)', 'Liam');
SELECT room, during, guest FROM room_bookings ORDER BY lower(during);
Result
CREATE EXTENSION
CREATE TABLE
INSERT 0 1
INSERT 0 1
ERROR:  conflicting key value violates exclusion constraint "room_bookings_pkey"
DETAIL:  Key (room, during)=(101, ["2025-06-02 14:00:00+00","2025-06-04 11:00:00+00")) conflicts with existing key (room, during)=(101, ["2025-06-01 14:00:00+00","2025-06-03 11:00:00+00")).
 room |                       during                        | guest 
------+-----------------------------------------------------+-------
  101 | ["2025-06-01 14:00:00+00","2025-06-03 11:00:00+00") | Rohit
  101 | ["2025-06-03 11:00:00+00","2025-06-05 11:00:00+00") | Maya
(2 rows)
What’s happening
  1. [from, to) is a half-open range: it includes the start and excludes the end, so Rohit's checkout at 11:00 and Maya's check-in at 11:00 touch but don't overlap — accepted.
  2. Liam's booking overlaps Rohit's, and the primary key rejects it with the conflicting key in the message. The index behind it is a GiST index; btree_gist lets it combine the plain room column with the range.
  3. Before 18 you wrote the same rule as EXCLUDE USING gist (room WITH =, during WITH &&). MySQL has no range types or exclusion constraints; you'd store two columns and check overlaps in a transaction with locking reads (see Locking reads: FOR UPDATE and FOR SHARE).
AdvancedFair play: types only MySQL has

#SQL differences you'll hit first

Added

Most SQL runs unchanged on both. The differences you hit in the first hour of a port are small and mechanical — quoting, case, a few missing features — and a few are semantic, where the same query returns different rows.

The two you'll meet first: how identifiers are quoted, and whether strings compare case-sensitively.

MySQL 9.7

SQL
SELECT `name`, "name" FROM customers WHERE id = 1;
SELECT id, name FROM customers WHERE name = 'rohit';
SELECT id, name FROM customers WHERE name LIKE 'ro%';
Result
+-------+------+
| name  | name |
+-------+------+
| Rohit | name |
+-------+------+
+----+-------+
| id | name  |
+----+-------+
|  1 | Rohit |
+----+-------+
+----+-------+
| id | name  |
+----+-------+
|  1 | Rohit |
+----+-------+

PostgreSQL 18

SQL
SELECT `name` FROM customers WHERE id = 1;
SELECT "name", 'name' FROM customers WHERE id = 1;
SELECT id, name FROM customers WHERE name = 'rohit';
SELECT id, name FROM customers WHERE name ILIKE 'ro%';
Result
ERROR:  syntax error at or near "FROM"
LINE 1: SELECT `name` FROM customers WHERE id = 1;
                      ^
 name  | ?column? 
-------+----------
 Rohit | name
(1 row)

 id | name 
----+------
(0 rows)

 id | name  
----+-------
  1 | Rohit
(1 row)
What’s happening
  1. MySQL quotes identifiers with backticks; in its default mode, double quotes make a string, so "name" returned the literal text name, not the column. (With sql_mode = ANSI_QUOTES, MySQL treats double quotes as identifiers like the standard.)
  2. PostgreSQL follows the standard: double quotes are identifiers, single quotes are strings, and backticks are simply not SQL — a syntax error. Porting a MySQL query with backticks means replacing them, or better, using names that need no quoting.
  3. name = 'rohit' finds Rohit in MySQL (case-insensitive collation) and nothing in PostgreSQL (case-sensitive). The same WHERE clause returns different rows — the most dangerous kind of difference, because nothing errors.
  4. PostgreSQL's case-insensitive tools: ILIKE (case-insensitive LIKE), lower(name) = 'rohit' with an index on lower(name), the citext extension type, or a non-deterministic ICU collation. MySQL goes the other way: add COLLATE utf8mb4_0900_as_cs or use a _bin collation when you need case-sensitivity.
AdvancedIdentifier case: table names and folding

Table and column names follow different case rules in the two engines:

SQL
-- MySQL 9.7 (Linux)
SELECT COUNT(*) FROM Customers;
SELECT @@lower_case_table_names;
SELECT COUNT(*) FROM customers WHERE NAME = 'Maya';
-- PostgreSQL 18
SELECT COUNT(*) FROM Customers;
CREATE TABLE "OrderNotes" (id int);
SELECT * FROM OrderNotes;
SELECT * FROM "OrderNotes";
Result
-- MySQL:
ERROR 1146 (42S02): Table 'ch07.Customers' doesn't exist
+--------------------------+
| @@lower_case_table_names |
+--------------------------+
|                        0 |
+--------------------------+
+----------+
| COUNT(*) |
+----------+
|        1 |
+----------+
-- PostgreSQL:
 count 
-------
     6
(1 row)

CREATE TABLE
ERROR:  relation "ordernotes" does not exist
LINE 1: SELECT * FROM OrderNotes;
                      ^
 id 
----
(0 rows)
What’s happening
  1. In MySQL, table names map to files, so their case sensitivity depends on the operating system: on Linux (lower_case_table_names = 0) Customers and customers are different tables. The same app on macOS or Windows (where the default is 1 or 2) works — the classic "works on my laptop, fails in production" bug. Column names (NAME) are always case-insensitive.
  2. PostgreSQL folds unquoted identifiers to lower case: Customers becomes customers and works everywhere.
  3. But a name created with double quotes keeps its case, and from then on it must always be quoted: OrderNotes folds to ordernotes, which doesn't exist. ORMs that quote everything (some do by default) create exactly these tables. Use lower-case snake_case names in both databases and the whole problem disappears.
AdvancedDDL inside transactions

PostgreSQL's DDL is transactional; MySQL's isn't:

SQL
-- MySQL 9.7
START TRANSACTION;
CREATE TABLE ddl_test (id INT);
INSERT INTO ddl_test VALUES (1);
ROLLBACK;
SELECT COUNT(*) AS rows_after_rollback FROM ddl_test;
-- PostgreSQL 18
BEGIN;
CREATE TABLE ddl_test (id int);
INSERT INTO ddl_test VALUES (1);
ROLLBACK;
SELECT COUNT(*) AS rows_after_rollback FROM ddl_test;
Result
-- MySQL:
+---------------------+
| rows_after_rollback |
+---------------------+
|                   1 |
+---------------------+
-- PostgreSQL:
BEGIN
CREATE TABLE
INSERT 0 1
ROLLBACK
ERROR:  relation "ddl_test" does not exist
LINE 1: SELECT COUNT(*) AS rows_after_rollback FROM ddl_test;
                                                    ^
What’s happening
  1. MySQL: CREATE TABLE causes an implicit commit that ends the transaction. The INSERT then runs in autocommit mode and commits on its own, so ROLLBACK has nothing to undo: the table exists with 1 row.
  2. PostgreSQL: the CREATE TABLE is part of the transaction like any other statement, so ROLLBACK removes the table and its row — it never existed.
  3. Why it matters: a migration that fails halfway on PostgreSQL rolls back cleanly; on MySQL it leaves the schema half-changed and you fix it by hand. Migration tools for MySQL encourage one DDL statement per migration for that reason (see Migrations and Autocommit and implicit commits).
  4. Caveat: a few PostgreSQL commands can't run in a transaction block — CREATE INDEX CONCURRENTLY, VACUUM, CREATE DATABASE.
AdvancedSyntax that only one side has

Five everyday queries, each written the way one engine wants:

SQL
-- MySQL 9.7
UPDATE products p JOIN categories c ON c.id = p.category_id
SET p.stock = p.stock + 1 WHERE c.name = 'Books';
SELECT c.name AS category, p.name AS product FROM categories c FULL OUTER JOIN products p ON p.category_id = c.id;
SELECT name, price FROM products ORDER BY price DESC FETCH FIRST 2 ROWS ONLY;
SELECT customer_id, id, ordered_at FROM orders GROUP BY customer_id;
-- PostgreSQL 18
UPDATE products p SET stock = p.stock + 1
FROM categories c WHERE c.id = p.category_id AND c.name = 'Books';
SELECT c.name AS category, p.name AS product FROM categories c FULL OUTER JOIN products p ON p.category_id = c.id
WHERE p.id IS NULL OR c.id IS NULL;
SELECT name, price FROM products ORDER BY price DESC FETCH FIRST 2 ROWS ONLY;
SELECT DISTINCT ON (customer_id) customer_id, id, ordered_at FROM orders ORDER BY customer_id, ordered_at DESC;
SELECT customer_id, id, ordered_at FROM orders GROUP BY customer_id;
Result
-- MySQL:
ERROR 1064 (42000): 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 'FULL OUTER JOIN products p ON p.category_id = c.id' at line 1
ERROR 1064 (42000): 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 'FETCH FIRST 2 ROWS ONLY' at line 1
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'ch07.orders.id' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by
-- PostgreSQL:
UPDATE 2
  category   | product 
-------------+---------
 Electronics | 
(1 row)

    name     |  price  
-------------+---------
 MacBook Air | 1299.00
 iPhone 17   | 1099.00
(2 rows)

 customer_id | id |     ordered_at      
-------------+----+---------------------
           1 |  2 | 2025-03-15 12:30:00
           2 |  8 | 2025-04-05 15:40:00
           3 |  5 | 2025-03-01 07:45:00
           4 |  6 | 2025-03-20 21:10:00
           5 |  7 | 2025-04-02 13:05:00
(5 rows)

ERROR:  column "orders.id" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT customer_id, id, ordered_at FROM orders GROUP BY cust...
                                ^
What’s happening
  1. UPDATE with a join: MySQL writes UPDATE a JOIN b … SET; PostgreSQL writes UPDATE a SET … FROM b WHERE. Both are non-standard, and neither accepts the other's form. (The MySQL statement succeeded silently, so it printed nothing.)
  2. FULL OUTER JOIN exists in PostgreSQL — here it finds the one category with no products (Electronics, a parent category). MySQL doesn't support it; emulate it with LEFT JOIN … UNION … RIGHT JOIN (see FULL OUTER JOIN in MySQL).
  3. FETCH FIRST n ROWS ONLY is the SQL-standard spelling of LIMIT; PostgreSQL accepts both, MySQL only LIMIT.
  4. DISTINCT ON (customer_id) is PostgreSQL's shortcut for "first row per group by the ORDER BY": each customer's latest order. MySQL needs ROW_NUMBER() (see Top N per group).
  5. GROUP BY strictness is now the same: with ONLY_FULL_GROUP_BY (default since MySQL 5.7.5) both reject a non-aggregated column that isn't functionally dependent on the GROUP BY. Old MySQL code that relied on "pick any row's value" fails on both.
AdvancedReference: common syntax side by side
Predict the result on each engine

A login form looks up the user by the email they typed, with the sample data loaded in both databases:

SQL
SELECT COUNT(*) AS matches FROM customers WHERE email = 'ROHIT@example.com';
Show answer

MySQL finds Rohit, PostgreSQL finds nobody:

Result
-- MySQL:
+---------+
| matches |
+---------+
|       1 |
+---------+
-- PostgreSQL:
 matches 
---------
       0
(1 row)
What’s happening
  1. The stored email is rohit@example.com, all lower case.
  2. MySQL compares with the column's collation, utf8mb4_0900_ai_ci — case-insensitive — so ROHIT@… equals rohit@…: 1 match.
  3. PostgreSQL's default collation is deterministic and case-sensitive: different bytes, no match. After a port, users who type their email with a capital letter suddenly "don't exist".
  4. Fix in PostgreSQL: normalise on write (lower(email) stored, with a CHECK (email = lower(email))), or compare lower(email) = lower($1) backed by a unique index on lower(email), or use the citext type.
A failed statement inside a transaction

The same script runs on both. How many rows survive?

SQL
CREATE TABLE quiz_t (id INT PRIMARY KEY);
BEGIN;
INSERT INTO quiz_t VALUES (1);
INSERT INTO quiz_t VALUES (1);
INSERT INTO quiz_t VALUES (2);
COMMIT;
SELECT COUNT(*) AS rows_kept FROM quiz_t;
Show answer

MySQL keeps 2 rows; PostgreSQL keeps 0.

Result
-- MySQL:
ERROR 1062 (23000): Duplicate entry '1' for key 'quiz_t.PRIMARY'
+-----------+
| rows_kept |
+-----------+
|         2 |
+-----------+
-- PostgreSQL:
CREATE TABLE
BEGIN
INSERT 0 1
ERROR:  duplicate key value violates unique constraint "quiz_t_pkey"
DETAIL:  Key (id)=(1) already exists.
ERROR:  current transaction is aborted, commands ignored until end of transaction block
ROLLBACK
 rows_kept 
-----------
         0
(1 row)
What’s happening
  1. In MySQL a failed statement rolls back only that statement. The transaction stays open, INSERT … (2) succeeds, and COMMIT commits rows 1 and 2. It's up to the application to notice the error and decide whether to roll back.
  2. In PostgreSQL an error puts the whole transaction into an aborted state: every following statement fails with "current transaction is aborted", and COMMIT actually performs a ROLLBACK (psql prints ROLLBACK). Row 1 is gone too.
  3. To keep going after an expected error in PostgreSQL, wrap the risky statement in a SAVEPOINT and ROLLBACK TO SAVEPOINT on failure (or use ON CONFLICT DO NOTHING here).
  4. Code ported from MySQL that "ignores duplicate errors and carries on" silently loses whole transactions on PostgreSQL — another difference that produces no error you'd notice in a log.

#Upserts and RETURNING

Added

An upsert is "insert this row, or if it already exists, update it" in one atomic statement. Both databases decide "already exists" by a unique index conflict, and both do it without a race between a separate SELECT and INSERT. The syntax and what you get back differ:

  • MySQL: INSERT … ON DUPLICATE KEY UPDATE — reacts to a conflict on any unique index. You can't get the row back; you get an affected-rows count and LAST_INSERT_ID().
  • PostgreSQL: INSERT … ON CONFLICT (columns) DO UPDATE (or DO NOTHING) — you name which unique constraint you mean, and RETURNING hands back the resulting rows. PostgreSQL 18 lets RETURNING see both the old and the new version.

Picture a guest list at a door. MySQL's bouncer checks every list (any unique key) and then shouts a number at you: "1!" (new guest), "2!" (updated), "0!" (nothing to change). PostgreSQL's bouncer checks the list you named and hands you a card saying exactly who's inside now — and, in 18, who was there before.

MySQL 9.7

SQL
CREATE TABLE inventory (
  id  INT AUTO_INCREMENT PRIMARY KEY,
  sku VARCHAR(20) NOT NULL UNIQUE,
  qty INT NOT NULL
);
INSERT INTO inventory (sku, qty) VALUES ('USB-C', 5) AS new
  ON DUPLICATE KEY UPDATE qty = inventory.qty + new.qty;
INSERT INTO inventory (sku, qty) VALUES ('USB-C', 3) AS new
  ON DUPLICATE KEY UPDATE qty = inventory.qty + new.qty;
INSERT INTO inventory (sku, qty) VALUES ('USB-C', 0) AS new
  ON DUPLICATE KEY UPDATE qty = inventory.qty + new.qty;
INSERT INTO inventory (sku, qty) VALUES ('HDMI', 2) AS new
  ON DUPLICATE KEY UPDATE qty = inventory.qty + new.qty;
SELECT * FROM inventory ORDER BY id;
INSERT INTO inventory (sku, qty) VALUES ('HDMI', 1) RETURNING id, qty;
Result
Query OK, 0 rows affected (0.024 sec)
Query OK, 1 row affected (0.002 sec)
Query OK, 2 rows affected (0.002 sec)
Query OK, 0 rows affected (0.002 sec)
Query OK, 1 row affected (0.002 sec)
+----+-------+-----+
| id | sku   | qty |
+----+-------+-----+
|  1 | USB-C |   8 |
|  4 | HDMI  |   2 |
+----+-------+-----+
ERROR 1064 (42000): 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, qty' at line 1

PostgreSQL 18

SQL
CREATE TABLE inventory (
  id  int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  sku varchar(20) NOT NULL UNIQUE,
  qty int NOT NULL
);
INSERT INTO inventory (sku, qty) VALUES ('USB-C', 5)
  ON CONFLICT (sku) DO UPDATE SET qty = inventory.qty + EXCLUDED.qty
  RETURNING id, old.qty AS old_qty, new.qty AS new_qty, (old IS NULL) AS inserted;
INSERT INTO inventory (sku, qty) VALUES ('USB-C', 3)
  ON CONFLICT (sku) DO UPDATE SET qty = inventory.qty + EXCLUDED.qty
  RETURNING id, old.qty AS old_qty, new.qty AS new_qty, (old IS NULL) AS inserted;
INSERT INTO inventory (sku, qty) VALUES ('USB-C', 0)
  ON CONFLICT (sku) DO UPDATE SET qty = inventory.qty + EXCLUDED.qty
  WHERE EXCLUDED.qty <> 0
  RETURNING id, old.qty AS old_qty, new.qty AS new_qty;
INSERT INTO inventory (sku, qty) VALUES ('HDMI', 2)
  ON CONFLICT (sku) DO UPDATE SET qty = inventory.qty + EXCLUDED.qty
  RETURNING id, old.qty AS old_qty, new.qty AS new_qty, (old IS NULL) AS inserted;
SELECT * FROM inventory ORDER BY id;
Result
CREATE TABLE
 id | old_qty | new_qty | inserted 
----+---------+---------+----------
  1 |         |       5 | t
(1 row)

INSERT 0 1
 id | old_qty | new_qty | inserted 
----+---------+---------+----------
  1 |       5 |       8 | f
(1 row)

INSERT 0 1
 id | old_qty | new_qty 
----+---------+---------
(0 rows)

INSERT 0 0
 id | old_qty | new_qty | inserted 
----+---------+---------+----------
  4 |         |       2 | t
(1 row)

INSERT 0 1
 id |  sku  | qty 
----+-------+-----
  1 | USB-C |   8
  4 | HDMI  |   2
(2 rows)
What’s happening
  1. First USB-C: no conflict, so both insert a row with id 1. MySQL reports 1 row affected; PostgreSQL's RETURNING shows old_qty empty (there was no old row) and inserted = t.
  2. Second USB-C: the unique sku conflicts, so both update qty to 5 + 3 = 8. MySQL signals "updated" with the odd convention 2 rows affected. PostgreSQL returns the row with old_qty 5 → new_qty 8.
  3. Third USB-C with qty 0: MySQL "updates" 8 to 8 + 0, nothing changes, so 0 rows affected. PostgreSQL can skip such updates explicitly with DO UPDATE … WHERE, and returns no row (INSERT 0 0).
  4. In MySQL the new row's values are referred to by a row alias (AS new, then new.qty, MySQL 8.0.19+); the older VALUES(qty) function still works but is deprecated. PostgreSQL uses the special EXCLUDED row.
  5. HDMI gets id 4 in both — ids 2 and 3 were consumed by the two upserts that ended up updating. Both databases allocate the auto-increment / identity value before they know there will be a conflict. Upsert-heavy tables burn ids quickly; size them as BIGINT.
  6. And the last line: MySQL has no RETURNING (a syntax error); to read back what an upsert did you run another query.
AdvancedGetting the id back in MySQL

The standard trick passes the existing id through LAST_INSERT_ID(expr), so the function returns the right id whether the row was inserted or updated:

SQL
INSERT INTO inventory (sku, qty) VALUES ('USB-C', 2) AS new
  ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(inventory.id), qty = inventory.qty + new.qty;
SELECT LAST_INSERT_ID() AS affected_id;
SELECT * FROM inventory ORDER BY id;
Result
+-------------+
| affected_id |
+-------------+
|           1 |
+-------------+
+----+-------+-----+
| id | sku   | qty |
+----+-------+-----+
|  1 | USB-C |  10 |
|  4 | HDMI  |   2 |
+----+-------+-----+
What’s happening
  1. USB-C exists, so the update branch runs. id = LAST_INSERT_ID(inventory.id) sets id to itself (no change) and, as a side effect, stores 1 as this connection's "last insert id".
  2. SELECT LAST_INSERT_ID() returns 1 — the updated row. Without the trick it would return 0 or a stale value after an update.
  3. Drivers expose the same value (insertId in mysql2's result), so one round trip plus one cheap function call gets you the id.
AdvancedMERGE: PostgreSQL 15+, not in MySQL

MERGE (SQL standard) applies a whole source table at once, with different actions for matched and unmatched rows. PostgreSQL 17 added RETURNING and merge_action():

SQL
-- PostgreSQL 18
CREATE TABLE deliveries (sku varchar(20), qty int);
INSERT INTO deliveries VALUES ('USB-C', 2), ('HDMI', -2), ('DP-cable', 7);
MERGE INTO inventory i
USING deliveries d ON d.sku = i.sku
WHEN MATCHED AND i.qty + d.qty <= 0 THEN DELETE
WHEN MATCHED THEN UPDATE SET qty = i.qty + d.qty
WHEN NOT MATCHED THEN INSERT (sku, qty) VALUES (d.sku, d.qty)
RETURNING merge_action() AS action, d.sku, old.qty AS old_qty, new.qty AS new_qty;
SELECT * FROM inventory ORDER BY id;
-- MySQL 9.7
MERGE INTO inventory USING (SELECT 'HDMI' AS sku) s ON inventory.sku = s.sku WHEN MATCHED THEN UPDATE SET qty = qty + 1;
Result
CREATE TABLE
INSERT 0 3
 action |   sku    | old_qty | new_qty 
--------+----------+---------+---------
 UPDATE | USB-C    |       8 |      10
 DELETE | HDMI     |       2 |        
 INSERT | DP-cable |         |       7
(3 rows)

MERGE 3
 id |   sku    | qty 
----+----------+-----
  1 | USB-C    |  10
  5 | DP-cable |   7
(2 rows)

-- MySQL:
ERROR 1064 (42000): 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 'MERGE INTO inventory USING (SELECT 'HDMI' AS sku) s ON inventory.sku = s.sku WHE' at line 1
What’s happening
  1. Three deliveries are matched against inventory by sku.
  2. USB-C matches and stays positive → UPDATE, 8 → 10. HDMI matches but would drop to 0 → DELETE (the first matching WHEN wins, so the order of the clauses matters). DP-cable doesn't exist → INSERT, getting id 5.
  3. RETURNING merge_action() reports what happened to each row — one statement, one round trip, a full audit of the change. (The PostgreSQL inventory had 8 USB-Cs: the LAST_INSERT_ID card above ran on MySQL only.)
  4. MERGE isn't a drop-in replacement for ON CONFLICT: under concurrent inserts it can still fail with a unique violation, while ON CONFLICT is designed to be race-free. Use ON CONFLICT for single-row upserts and MERGE for batch synchronisation.
  5. MySQL doesn't support MERGE at all.
AdvancedPitfall: MySQL upserts react to every unique key

#MVCC, VACUUM and undo logs

Added

Both databases use multi-version concurrency control: readers see a consistent snapshot without blocking writers, because old versions of changed rows are kept until nobody can see them any more. The difference is where the old versions live — and that decides how each one must clean up.

  • InnoDB (MySQL) updates the row in place in the table and copies the old values into an undo log. A reader that needs the old version follows a pointer from the row into undo and rebuilds it. A background purge thread discards undo records once no transaction needs them.
  • PostgreSQL never updates a row in place. An UPDATE writes a complete new copy of the row (a new tuple) and marks the old one as expired. Both sit in the table. VACUUM later finds tuples that no snapshot can see and makes their space reusable.

The analogy: InnoDB keeps one master copy of each document on the shelf and puts the previous drafts in an archive box (undo) that's emptied regularly. PostgreSQL puts each new draft on the shelf next to the old one, and a cleaner (VACUUM) comes round to bin the drafts nobody is reading.

The quickest way to see the difference: update every row of the same 100,000-row table in both, and look at the table's size.

MySQL 9.7

SQL
CREATE TABLE mvcc_demo (id INT PRIMARY KEY, val INT NOT NULL, pad CHAR(100) NOT NULL DEFAULT 'x');
SET SESSION cte_max_recursion_depth = 100000;
INSERT INTO mvcc_demo (id, val)
  WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM s WHERE n < 100000) SELECT n, 0 FROM s;
SELECT FILE_SIZE / 1048576 AS file_mb_before FROM information_schema.innodb_tablespaces WHERE name = 'ch07/mvcc_demo';
UPDATE mvcc_demo SET val = val + 1;
SELECT FILE_SIZE / 1048576 AS file_mb_after_update FROM information_schema.innodb_tablespaces WHERE name = 'ch07/mvcc_demo';
Result
+----------------+
| file_mb_before |
+----------------+
|        21.0000 |
+----------------+
+----------------------+
| file_mb_after_update |
+----------------------+
|              21.0000 |
+----------------------+

PostgreSQL 18

SQL
CREATE TABLE mvcc_demo (id int PRIMARY KEY, val int NOT NULL, pad char(100) NOT NULL DEFAULT 'x')
  WITH (autovacuum_enabled = false);
INSERT INTO mvcc_demo (id, val) SELECT n, 0 FROM generate_series(1, 100000) AS n;
SELECT round(pg_relation_size('mvcc_demo') / 1048576.0, 1) AS file_mb_before;
UPDATE mvcc_demo SET val = val + 1;
SELECT round(pg_relation_size('mvcc_demo') / 1048576.0, 1) AS file_mb_after_update;
DO $$ BEGIN PERFORM pg_stat_force_next_flush(); END $$;
SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'mvcc_demo';
Result
CREATE TABLE
INSERT 0 100000
 file_mb_before 
----------------
           13.5
(1 row)

UPDATE 100000
 file_mb_after_update 
----------------------
                 26.9
(1 row)

DO
 n_live_tup | n_dead_tup 
------------+------------
     100000 |     100000
(1 row)
What’s happening
  1. Both tables get 100,000 identical rows. (MySQL's .ibd file is 21 MB because InnoDB allocates space in extents ahead of time; PostgreSQL's heap is 13.5 MB. Compare each with itself, not with the other.)
  2. MySQL: after updating every row, the file is still 21 MB. Each row was changed in place; the old val went to the undo log, which lives in separate undo tablespaces, not in the table.
  3. PostgreSQL: the table doubled to 26.9 MB. Every UPDATE wrote a full new tuple, and the 100,000 old ones are still there: n_dead_tup = 100000 next to n_live_tup = 100000.
  4. Autovacuum was switched off for this table so the dead tuples stay visible; normally the autovacuum daemon notices them (by default once about 20% of a table is dead) and vacuums the table in the background.
  5. Neither design is "better": PostgreSQL's makes rollbacks instant (nothing to undo — the new tuples just stay invisible) and keeps old versions cheap to read, but needs VACUUM; InnoDB's keeps tables compact but makes rollbacks of big transactions slow (undo must be applied) and readers of old snapshots pay to rebuild versions.
AdvancedInside a PostgreSQL page: xmin, xmax and the dead tuple

The pageinspect extension shows the tuples physically on a page, including the dead one:

SQL
CREATE EXTENSION IF NOT EXISTS pageinspect;
CREATE TABLE accounts (id int PRIMARY KEY, balance int NOT NULL) WITH (autovacuum_enabled = false);
INSERT INTO accounts VALUES (1, 100);
SELECT ctid, xmin, xmax, * FROM accounts;
UPDATE accounts SET balance = 90 WHERE id = 1;
SELECT ctid, xmin, xmax, * FROM accounts;
SELECT lp, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('accounts', 0));
VACUUM accounts;
SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid FROM heap_page_items(get_raw_page('accounts', 0));
Result
CREATE EXTENSION
CREATE TABLE
INSERT 0 1
 ctid  | xmin | xmax | id | balance 
-------+------+------+----+---------
 (0,1) |  870 |    0 |  1 |     100
(1 row)

UPDATE 1
 ctid  | xmin | xmax | id | balance 
-------+------+------+----+---------
 (0,2) |  871 |    0 |  1 |      90
(1 row)

 lp | t_xmin | t_xmax | t_ctid 
----+--------+--------+--------
  1 |    870 |    871 | (0,2)
  2 |    871 |      0 | (0,2)
(2 rows)

VACUUM
 lp | lp_flags | t_xmin | t_xmax | t_ctid 
----+----------+--------+--------+--------
  1 |        2 |        |        | 
  2 |        1 |    871 |      0 | (0,2)
(2 rows)
What’s happening
  1. Every tuple carries xmin (the transaction that created it) and xmax (the one that deleted or replaced it; 0 = still current). ctid is its physical address: page 0, item 1.
  2. After the update, the visible row is a new tuple at (0,2), created by transaction 871. The query only shows what your snapshot can see.
  3. heap_page_items shows the page itself: item 1 (balance 100) still exists, now with xmax = 871 and a t_ctid pointing to its successor (0,2). A transaction whose snapshot started before 871 committed would still see item 1 — that's MVCC.
  4. After VACUUM, item 1's tuple data is gone and its line pointer has lp_flags = 2 (a redirect) to item 2. This was a HOT update ("heap-only tuple"): balance isn't indexed and the new version fit on the same page, so the index still points at item 1 and is redirected — no index entries had to change. Updating an indexed column, or a full page, prevents HOT and makes updates more expensive.
AdvancedVACUUM, space reuse and VACUUM FULL

Back to the bloated 100,000-row table: vacuum it, update it again, then compact it.

SQL
VACUUM mvcc_demo;
DO $$ BEGIN PERFORM pg_stat_force_next_flush(); END $$;
SELECT round(pg_relation_size('mvcc_demo') / 1048576.0, 1) AS file_mb, n_dead_tup
FROM pg_stat_user_tables WHERE relname = 'mvcc_demo';
UPDATE mvcc_demo SET val = val + 1;
SELECT round(pg_relation_size('mvcc_demo') / 1048576.0, 1) AS file_mb_after_second_update;
VACUUM FULL mvcc_demo;
SELECT round(pg_relation_size('mvcc_demo') / 1048576.0, 1) AS file_mb_after_vacuum_full;
Result
VACUUM
DO
 file_mb | n_dead_tup 
---------+------------
    26.9 |          0
(1 row)

UPDATE 100000
 file_mb_after_second_update 
-----------------------------
                        26.9
(1 row)

VACUUM
 file_mb_after_vacuum_full 
---------------------------
                      13.5
(1 row)
What’s happening
  1. Plain VACUUM removes the 100,000 dead tuples (n_dead_tup = 0) but the file stays 26.9 MB: the freed space is recorded in the free space map for reuse, not returned to the operating system. It runs alongside normal reads and writes.
  2. A second full-table update now fits into that free space, so the table doesn't grow again. This is the steady state you want: autovacuum keeps up, and the table settles at roughly "live data + room for one round of updates".
  3. VACUUM FULL rewrites the table compactly — back to 13.5 MB — but takes an ACCESS EXCLUSIVE lock for the whole rewrite, blocking even reads. On a live system use the pg_repack extension instead, which rebuilds with only brief locks.
  4. Tables that grow because vacuum can't keep up are called bloated; the usual causes are long-running transactions (next card), autovacuum settings too gentle for a hot table, or vacuum being blocked.
AdvancedThe shared enemy: long-running transactions

In both engines an old snapshot keeps old versions alive. Here a REPEATABLE READ transaction stays open while another session updates the row three times:

SQL
-- PostgreSQL 18, session A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;
SELECT pg_sleep(8);
SELECT balance AS still_sees FROM accounts WHERE id = 1;
COMMIT;
-- session B, while A is open
UPDATE accounts SET balance = balance - 10 WHERE id = 1;  -- three times
VACUUM (VERBOSE) accounts;
-- session B, after A has committed
VACUUM (VERBOSE) accounts;
SELECT balance FROM accounts;
Result
-- Session B (A still open):
UPDATE 1
UPDATE 1
UPDATE 1
INFO:  vacuuming "ch07.public.accounts"
INFO:  finished vacuuming "ch07.public.accounts": index scans: 0
pages: 0 removed, 1 remain, 1 scanned (100.00% of total), 0 eagerly scanned
tuples: 0 removed, 4 remain, 3 are dead but not yet removable
removable cutoff: 872, which was 3 XIDs old when operation ended
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
visibility map: 0 pages set all-visible, 0 pages set all-frozen (0 were all-visible)
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed
avg read rate: 0.000 MB/s, avg write rate: 58.302 MB/s
buffer usage: 24 hits, 0 reads, 1 dirtied
WAL usage: 1 records, 1 full page images, 8291 bytes, 0 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
VACUUM
-- Session A:
BEGIN
 balance 
---------
      90
(1 row)

 pg_sleep 
----------
 
(1 row)

 still_sees 
------------
         90
(1 row)

COMMIT
-- Session B (after A committed):
INFO:  vacuuming "ch07.public.accounts"
INFO:  finished vacuuming "ch07.public.accounts": index scans: 0
pages: 0 removed, 1 remain, 1 scanned (100.00% of total), 0 eagerly scanned
tuples: 3 removed, 1 remain, 0 are dead but not yet removable
removable cutoff: 875, which was 0 XIDs old when operation ended
new relfrozenxid: 874, which is 3 XIDs ahead of previous value
frozen: 0 pages from table (0.00% of total) had 0 tuples frozen
visibility map: 1 pages set all-visible, 0 pages set all-frozen (0 were all-visible)
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed
avg read rate: 0.000 MB/s, avg write rate: 0.000 MB/s
buffer usage: 57 hits, 0 reads, 0 dirtied
WAL usage: 3 records, 0 full page images, 432 bytes, 0 buffers full
system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s
VACUUM
 balance 
---------
      60
(1 row)
What’s happening
  1. Session A's snapshot was taken when the balance was 90. Session B then updates it three times (to 60), creating three new tuples and leaving three dead ones.
  2. VACUUM can't remove them: "3 are dead but not yet removable". The removable cutoff is held back by A's snapshot — A might still need any of those versions. A keeps seeing 90 throughout, as REPEATABLE READ promises.
  3. Once A commits, the next VACUUM reports "3 removed" and "0 are dead but not yet removable". The tuples: and removable cutoff: lines are the ones to read in VACUUM VERBOSE output.
  4. InnoDB has the same problem in a different place: an open snapshot stops purge, so the undo history grows. Running the equivalent test on MySQL — one START TRANSACTION WITH CONSISTENT SNAPSHOT held open while 1,000 single-row updates ran — took the history list length (information_schema.innodb_metrics, trx_rseg_history_len) from 0 to 1001, and the open snapshot kept reading the old value. Note that this counter counts transactions' undo logs, not rows, and InnoDB frees them in batches, so on this server it didn't fall back immediately after the snapshot closed.
  5. On both: watch for old transactions (pg_stat_activity.xact_start, information_schema.innodb_trx.trx_started), set timeouts such as PostgreSQL's idle_in_transaction_session_timeout, and keep reporting queries off the primary.
AdvancedEdge case: transaction ID wraparound

#Indexes and extensions

Added

Both databases default to B-tree indexes, and for most OLTP queries that's all you need. Beyond that they differ a lot:

  • MySQL / InnoDB: B-tree (the table itself is a clustered B-tree on the primary key), FULLTEXT (inverted index for words), SPATIAL (R-tree for geometry), multi-valued indexes over JSON arrays, functional indexes, and invisible indexes. That's the whole list — and it's fixed.
  • PostgreSQL: B-tree, hash, GiST (geometric, ranges, nearest-neighbour), SP-GiST (space-partitioned trees), GIN (inverted index for arrays, jsonb, full-text, trigrams) and BRIN (tiny block-range summaries for huge, naturally ordered tables). Plus partial indexes (WHERE …), expression indexes, covering indexes with INCLUDE, and CREATE INDEX CONCURRENTLY. Tables are heaps, not clustered.

The bigger difference is extensibility. A PostgreSQL extension can add types, operators, functions and whole index methods — PostGIS, pgvector, pg_trgm, TimescaleDB are all extensions. MySQL extends through plugins and components, which can't add new index types to InnoDB.

MySQL 9.7

SQL
CREATE TABLE idx_kinds (
  id    INT PRIMARY KEY,
  email VARCHAR(100) NOT NULL,
  bio   TEXT NOT NULL,
  spot  POINT NOT NULL SRID 4326,
  tags  JSON NOT NULL
);
CREATE INDEX idx_email_hash ON idx_kinds (email) USING HASH;
CREATE FULLTEXT INDEX idx_bio ON idx_kinds (bio);
CREATE SPATIAL INDEX idx_spot ON idx_kinds (spot);
CREATE INDEX idx_tags ON idx_kinds ((CAST(tags AS CHAR(20) ARRAY)));
SELECT index_name, index_type FROM information_schema.statistics
WHERE table_schema = 'ch07' AND table_name = 'idx_kinds' ORDER BY index_name;
Result
Note (Code 3502): This storage engine does not support the HASH index algorithm, storage engine default was used instead.
Warning (Code 124): InnoDB rebuilding table to add column FTS_DOC_ID
+----------------+------------+
| INDEX_NAME     | INDEX_TYPE |
+----------------+------------+
| idx_bio        | FULLTEXT   |
| idx_email_hash | BTREE      |
| idx_spot       | SPATIAL    |
| idx_tags       | BTREE      |
| PRIMARY        | BTREE      |
+----------------+------------+

PostgreSQL 18

SQL
SELECT amname FROM pg_am WHERE amtype = 'i' ORDER BY oid;
Result
 amname 
--------
 btree
 hash
 gist
 gin
 brin
 spgist
(6 rows)
What’s happening
  1. MySQL: asking InnoDB for a HASH index silently gives you a B-tree — only a Note 3502 says so, and the index is still named idx_email_hash. (InnoDB has an internal adaptive hash index it builds by itself; you can't create one.)
  2. FULLTEXT works but InnoDB adds a hidden FTS_DOC_ID column the first time, which rebuilds the table (Warning 124) — worth knowing before running it on a big table.
  3. SPATIAL indexes need a NOT NULL geometry column with an SRID. The multi-valued index over a JSON array is reported as an ordinary BTREE.
  4. PostgreSQL lists its index access methods in pg_am: six built in, and extensions can add more (pgvector adds hnsw and ivfflat, for example). A GIN index is what makes jsonb @> and trigram searches fast; a BRIN index on a time-ordered log table can be a few kilobytes where a B-tree would be gigabytes.
  5. The practical upshot: in MySQL, unusual access patterns (fuzzy search, geo-radius, vectors) often mean a second system (Elasticsearch, a vector database); in PostgreSQL they're often an extension away.
AdvancedPartial indexes: PostgreSQL yes, MySQL no

A partial index covers only the rows matching a WHERE clause — smaller, and only maintained for those rows:

SQL
-- PostgreSQL 18 (events: 200,000 rows, a third of them 'buy')
CREATE INDEX idx_events_buy_customer ON events ((payload->>'customer_id')) WHERE payload->>'type' = 'buy';
ANALYZE events;
EXPLAIN (COSTS OFF)
SELECT count(*) FROM events WHERE payload->>'type' = 'buy' AND payload->>'customer_id' = '3';
EXPLAIN (COSTS OFF)
SELECT count(*) FROM events WHERE payload->>'type' = 'view' AND payload->>'customer_id' = '3';
SELECT pg_size_pretty(pg_relation_size('idx_events_buy_customer')) AS partial_index_size;
-- MySQL 9.7
CREATE INDEX idx_pending ON orders (customer_id) WHERE status = 'pending';
Result
                             QUERY PLAN                              
---------------------------------------------------------------------
 Aggregate
   ->  Index Scan using idx_events_buy_customer on events
         Index Cond: ((payload ->> 'customer_id'::text) = '3'::text)
(3 rows)

                                                          QUERY PLAN                                                           
-------------------------------------------------------------------------------------------------------------------------------
 Finalize Aggregate
   ->  Gather
         Workers Planned: 1
         ->  Partial Aggregate
               ->  Parallel Seq Scan on events
                     Filter: (((payload ->> 'type'::text) = 'view'::text) AND ((payload ->> 'customer_id'::text) = '3'::text))
(6 rows)

 partial_index_size 
--------------------
 472 kB
(1 row)

-- MySQL:
ERROR 1064 (42000): 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 'WHERE status = 'pending'' at line 1
What’s happening
  1. The index only contains buy events. A query whose WHERE implies the index's predicate (type = 'buy') can use it: Index Scan on idx_events_buy_customer.
  2. The same query for view events can't — those rows aren't in the index — so PostgreSQL runs a Parallel Seq Scan with one extra worker.
  3. Common uses: indexing only unprocessed jobs (WHERE processed_at IS NULL), only live rows (WHERE deleted_at IS NULL, the soft-delete unique index from Soft deletes, audit columns and history tables), only rare statuses.
  4. MySQL has no partial indexes (ERROR 1064). The workaround is a functional index or generated column that is NULL for rows you don't care about.
AdvancedExtensions: trigram search for '%book%'

A LIKE '%book%' search can't use a normal B-tree (there's no fixed prefix). PostgreSQL's pg_trgm extension indexes three-letter fragments with GIN, so it can:

SQL
-- PostgreSQL 18
SELECT count(*) AS available_extensions FROM pg_available_extensions;
SELECT name, default_version FROM pg_available_extensions
WHERE name IN ('pg_trgm', 'pgcrypto', 'postgis', 'vector', 'pg_stat_statements', 'hstore', 'citext', 'btree_gist', 'pageinspect')
ORDER BY name;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE product_names AS SELECT n AS id, 'Product ' || md5(n::text) AS name FROM generate_series(1, 100000) n;
INSERT INTO product_names VALUES (100001, 'ThinkBook 14'), (100002, 'Chromebook Plus');
CREATE INDEX idx_names_trgm ON product_names USING gin (name gin_trgm_ops);
ANALYZE product_names;
EXPLAIN (ANALYZE, COSTS OFF, BUFFERS OFF, SUMMARY OFF)
SELECT id, name FROM product_names WHERE name ILIKE '%book%';
-- MySQL 9.7, the same 100,002 names with a B-tree index on name
EXPLAIN ANALYZE SELECT id, name FROM product_names WHERE name LIKE '%book%';
Result
 available_extensions 
----------------------
                   46
(1 row)

        name        | default_version 
--------------------+-----------------
 btree_gist         | 1.8
 citext             | 1.8
 hstore             | 1.8
 pageinspect        | 1.13
 pg_stat_statements | 1.12
 pg_trgm            | 1.6
 pgcrypto           | 1.4
(7 rows)

                                       QUERY PLAN                                       
----------------------------------------------------------------------------------------
 Bitmap Heap Scan on product_names (actual time=0.015..0.016 rows=2.00 loops=1)
   Recheck Cond: (name ~~* '%book%'::text)
   Heap Blocks: exact=1
   ->  Bitmap Index Scan on idx_names_trgm (actual time=0.009..0.009 rows=2.00 loops=1)
         Index Cond: (name ~~* '%book%'::text)
         Index Searches: 1
(6 rows)

-- MySQL:
EXPLAIN: -> Filter: (product_names.`name` like '%book%')  (cost=10092 rows=11079) (actual time=0.0378..19.1 rows=2 loops=1)
    -> Covering index scan on product_names using idx_name  (cost=10092 rows=99722) (actual time=0.0361..6.13 rows=100002 loops=1)
What’s happening
  1. The stock postgres:18 image ships 46 contrib extensions — pg_trgm, pgcrypto, hstore, citext, pg_stat_statements and more. PostGIS and pgvector aren't among them: they're separate packages (or images), and managed services list which extensions they allow.
  2. CREATE EXTENSION pg_trgm adds the gin_trgm_ops operator class. The index splits each name into trigrams ("boo", "ook"…), so ILIKE '%book%' becomes an index lookup: 2 rows in 0.016 ms, case-insensitive, in the middle of words (ThinkBook, Chromebook).
  3. MySQL's best effort with a B-tree is a covering index scan of all 100,002 entries (19.1 ms) with the LIKE as a filter. MySQL's FULLTEXT indexes search whole words, so they wouldn't find book inside ThinkBook (the ngram parser can, at the cost of a large index).
  4. The same pattern repeats for geography (PostGIS vs MySQL's spatial functions), vectors (pgvector vs MySQL HeatWave) and time series (TimescaleDB): PostgreSQL's extension model is a large part of why it's chosen.
AdvancedBuilding indexes on a live table

Adding an index without blocking writers, and the one place it can't run:

SQL
-- PostgreSQL 18
CREATE INDEX CONCURRENTLY idx_events_id_type ON events ((payload->>'type')) INCLUDE (id);
BEGIN;
CREATE INDEX CONCURRENTLY idx_fail ON events (id);
ROLLBACK;
Result
CREATE INDEX
BEGIN
ERROR:  CREATE INDEX CONCURRENTLY cannot run inside a transaction block
ROLLBACK
What’s happening
  1. A plain CREATE INDEX in PostgreSQL blocks writes to the table while it builds. CONCURRENTLY builds in several passes without blocking writes — slower, and if it fails it leaves an INVALID index you must drop and retry.
  2. It can't run inside a transaction block, which is one of the few exceptions to "all DDL is transactional" — migration tools need a flag to run it outside their transaction.
  3. INCLUDE (id) stores extra columns in the index leaves without making them part of the key, so queries can be answered from the index alone (an index-only scan). MySQL gets the same effect differently: every InnoDB secondary index already contains the primary key, and you add other columns to the key itself.
  4. MySQL's equivalent of a concurrent build is ALTER TABLE … ADD INDEX …, ALGORITHM=INPLACE, LOCK=NONE (see Online schema changes).

#Choosing between them

Added

For a typical web application — users, orders, a few dozen tables, thousands of queries a second — either is a fine choice, and the team's experience matters more than the engine. The differences that should actually drive a decision are few:

ConsiderationLeans MySQLLeans PostgreSQL
Data modelSimple relational, high-volume OLTPRich types: jsonb, arrays, ranges, geography, vectors
Query complexityMostly key lookups and simple joinsComplex analytics, CTEs, FULL JOIN, DISTINCT ON, many index types
Correctness by defaultFine with strict mode on; watch case-insensitive collationsStrict types, transactional DDL, case-sensitive by default
Scaling outMature replication, Group Replication, Vitess (PlanetScale) for shardingLogical and physical replication, Citus for sharding, many managed options
ConnectionsThread per connection, cheaper connectionsProcess per connection; plan a pooler (PgBouncer)
Write-heavy updatesIn-place updates + undo, no vacuum to tuneWatch autovacuum and bloat on hot tables
EcosystemLAMP, WordPress, many PHP/legacy apps, MySQL-compatible clouds (Aurora MySQL, TiDB)Extensions (PostGIS, pgvector, Timescale), Postgres-compatible clouds (Aurora PostgreSQL, AlloyDB, Neon, Supabase)
LicenceGPLv2 (Oracle), commercial editions exist; MariaDB is a separate forkPostgreSQL Licence (permissive, BSD-like)

A reasonable default in 2026 for a new project with no constraints is PostgreSQL — not because MySQL is worse, but because PostgreSQL's richer types and extensions leave more room to grow without adding another database. MySQL remains an excellent choice for teams that know it, for very large, simple OLTP workloads with established sharding tooling (Vitess runs some of the largest MySQL fleets), and for applications built around it.

Things that shouldn't decide it: benchmarks run by vendors, "MySQL is faster for reads" (it depends on the query and the indexes), and 2010-era complaints about MySQL accepting bad data (strict mode has been the default since 5.7).

SQL and MySQL written fresh; MongoDB built from Rohit’s “mongoDB interview” doc. Every query was run on MySQL 9.7, MongoDB 9.0, Redis 8.10 or PostgreSQL 18.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.