Skip to content
About
Main content

Same name, different promise: compare engines

Run a real systemBreak it on purposeNeeds Docker, Python

Problem

names come from the ANSI SQL standard, but the standard's definitions are ambiguous, and each engine implements its own reading. PostgreSQL's is : every read in a transaction sees the same . 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 that block inserts into the range it read. The same name, two different promises.

Write one three-transaction test (a test): T1 runs SELECT count(*) FROM t WHERE x > 10, T2 inserts a row with x = 11 and , 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

At "Repeatable Read", after T1's locking read of a range, in which engine does T2's insert into that range block?

Run it

  1. 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 | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    same-name-different-promise-compare-engines.py93 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    same-name-different-promise-compare-engines.compose.yml22 linesDownload
    View 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: 60
    
  4. 4Start 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
Knobs
Knob · defaultWhat 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"

The phantom test at "Repeatable Read"
EngineFirst countSecond countFOR UPDATE seesT2 insert blocked
PostgreSQL333no
MySQL InnoDB334yes
Reference runApple M2 · 8 cores · 8 GB
Output · uv run same-name-different-promise-compare-engines.py
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)
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · pymysql 1.2.3 · postgres:17.6 · mysql:8.4 · Docker 28.5.1 · run 2026-10-20

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 (, , phantom), and test the engine for those, as the Elle-style lab does.