Same name, different promise: compare engines
Run a real systemBreak it on purposeNeeds Docker, Python
Problem
Isolation level names come from the ANSI SQL standard, but the standard's definitions are ambiguous, and each engine implements its own reading. PostgreSQL's Repeatable Read is snapshot isolation: every read in a transaction sees the same snapshot. MySQL's InnoDB Repeatable Read gives plain reads a snapshot too, but a locking read (SELECT … FOR UPDATE) reads the latest committed data, and it takes gap locks that block inserts into the range it read. The same name, two different promises.
Write one three-transaction test (a phantom test): T1 runs SELECT count(*) FROM t WHERE x > 10, T2 inserts a row with x = 11 and commits, T1 repeats the count and then inserts a row based on the first count. Run it at the level called "Repeatable Read" in PostgreSQL and in MySQL/InnoDB (for example with Docker). Record whether the second count changed, whether a plain SELECT and a SELECT ... FOR UPDATE give different numbers inside the same InnoDB transaction, and whether the final write is blocked.
The script runs both engines from one compose file, and adds a second test: after T1's locking read, does T2's insert into the range block?
The question. At "Repeatable Read", does the second count change in each engine, does a locking read see something a plain read does not, and does T1's locking read block T2's insert?
Predict
Expect the two engines to differ, which shows that a level name tells you who wrote it.
Run it
1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
same-name-different-promise-compare-engines.py # /// script # requires-python = ">=3.10" # dependencies = ["psycopg[binary]", "pymysql"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Same name, different promise: compare engines. One phantom test at the level both PostgreSQL and MySQL/InnoDB call "Repeatable Read": T1 counts rows with x > 10, T2 inserts x = 11 and commits, T1 counts again (plain and locking read) and inserts based on its first count. Then: does a locking read by T1 block T2's insert? Needs both services from the compose file (ports 55463 and 55464). """ import argparse, json, threading, time import psycopg, pymysql p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--pg", default="postgresql://postgres:lab@localhost:55463/postgres", help="PostgreSQL connection string.") p.add_argument("--mysql-port", type=int, default=55464, help="MySQL port.") p.add_argument("--block-timeout", type=float, default=3.0, help="Seconds to wait before calling T2's insert blocked.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() class Engine: def __init__(self, name): self.name = name def conn(self): if self.name == "PostgreSQL": c = psycopg.connect(args.pg); return c return pymysql.connect(host="127.0.0.1", port=args.mysql_port, user="root", password="lab", database="lab", autocommit=False) def q(self, c, sql, *a): cur = c.cursor(); cur.execute(sql, a or None); return cur def begin_rr(self, c): if self.name == "PostgreSQL": self.q(c, "BEGIN ISOLATION LEVEL REPEATABLE READ") else: self.q(c, "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"); self.q(c, "START TRANSACTION") def setup(self): c = self.conn() self.q(c, "DROP TABLE IF EXISTS t") self.q(c, "CREATE TABLE t (id int PRIMARY KEY, x int)" if self.name == "PostgreSQL" else "CREATE TABLE t (id int PRIMARY KEY, x int, KEY (x)) ENGINE=InnoDB") for i, x in enumerate((5, 12, 15, 20), start=1): self.q(c, "INSERT INTO t VALUES (%s, %s)", i, x) c.commit(); c.close() def phantom(e): e.setup() t1, t2 = e.conn(), e.conn() e.begin_rr(t1) first = e.q(t1, "SELECT count(*) FROM t WHERE x > 10").fetchone()[0] e.q(t2, "INSERT INTO t VALUES (100, 11)"); t2.commit() # T2 inserts x = 11, commits plain = e.q(t1, "SELECT count(*) FROM t WHERE x > 10").fetchone()[0] locking = len(e.q(t1, "SELECT id FROM t WHERE x > 10 FOR UPDATE").fetchall()) e.q(t1, "INSERT INTO t VALUES (200, %s)", first) # a write based on the first count t1.commit() t1.close(); t2.close() return {"engine": e.name, "first_count": first, "second_count_plain": plain, "count_for_update": locking} def blocking(e): e.setup() t1, t2 = e.conn(), e.conn() e.begin_rr(t1) e.q(t1, "SELECT id FROM t WHERE x > 10 FOR UPDATE").fetchall() # a locking read of the range done = {} def insert(): t = time.perf_counter() try: e.q(t2, "INSERT INTO t VALUES (300, 13)"); t2.commit() except Exception as ex: done["err"] = type(ex).__name__ done["s"] = time.perf_counter() - t th = threading.Thread(target=insert); th.start(); th.join(args.block_timeout) blocked = th.is_alive() t1.commit(); th.join() t1.close(); t2.close() return {"blocked": "yes" if blocked else "no", "insert_waited_s": done["s"]} rows = [] for e in (Engine("PostgreSQL"), Engine("MySQL InnoDB")): r = phantom(e) | blocking(e) rows.append(r) print(f"{e.name:<13} first count {r['first_count']}, second plain count {r['second_count_plain']}, " f"FOR UPDATE sees {r['count_for_update']}; after T1's locking read, T2's insert blocked: {r['blocked']} " f"(waited {r['insert_waited_s']:.2f} s)") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"engines": rows}}, f)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide the code
same-name-different-promise-compare-engines.compose.yml # PostgreSQL and MySQL (InnoDB), to run the same phantom test at each one's "Repeatable Read". services: postgres: image: postgres:17.6 environment: POSTGRES_PASSWORD: lab ports: ["55463:5432"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 1s retries: 30 mysql: image: mysql:8.4 environment: MYSQL_ROOT_PASSWORD: lab MYSQL_DATABASE: lab ports: ["55464:3306"] healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-plab"] interval: 2s retries: 604Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.
Terminal docker compose -f same-name-different-promise-compare-engines.compose.yml up -d --wait uv run same-name-different-promise-compare-engines.py uv run same-name-different-promise-compare-engines.py --help # every knob docker compose -f same-name-different-promise-compare-engines.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --pgpostgresql://postgres:lab@localhost:55463/postgres | PostgreSQL connection string. |
| --mysql-port55,464 | MySQL port. |
| --block-timeout3 | Seconds to wait before calling T2's insert blocked. |
Our run
The phantom test at "Repeatable Read"
| Engine | First count | Second count | FOR UPDATE sees | T2 insert blocked |
|---|---|---|---|---|
| PostgreSQL | 3 | 3 | 3 | no |
| MySQL InnoDB | 3 | 3 | 4 | yes |
PostgreSQL first count 3, second plain count 3, FOR UPDATE sees 3; after T1's locking read, T2's insert blocked: no (waited 0.00 s)
MySQL InnoDB first count 3, second plain count 3, FOR UPDATE sees 4; after T1's locking read, T2's insert blocked: yes (waited 3.02 s)What we found
Both engines kept the plain count stable: 3 in PostgreSQL and 3 in MySQL, the same as the first. They differed on the locking read. Inside the same InnoDB transaction, SELECT … FOR UPDATE saw 4 rows, including T2's new one, while PostgreSQL's saw 3. And after T1's locking read, T2's insert into the range was blocked in MySQL (yes, it waited 3.02 s) but not in PostgreSQL (no). Same level name, different answers, as expected.
Read it
A vendor's level name is a hypothesis about what it prevents, not a definition. PostgreSQL gives every read in the transaction one snapshot and detects conflicting writes; InnoDB mixes a snapshot for plain reads with current reads and range locks for locking reads, which prevents some phantoms by blocking writers instead.
Code that moves between engines, or an ORM that hides which one it talks to, can depend on behaviour that the next engine does not provide. Describe what your code needs as anomalies it must not see (lost update, write skew, phantom), and test the engine for those, as the Elle-style lab does.