Run an Elle-style check on your own database
Run a real systemBreak it on purposeNeeds Docker, Python
Problem
You cannot see an isolation anomaly from inside the database; it exists only in the combined history of many transactions. Jepsen's Elle checks it from outside with a list-append workload: each transaction appends a unique number to one list and reads others. Because every element is unique, each list's final order reveals the order in which the appends were applied, and every read reveals exactly which appends it saw. From that, the checker builds the dependency graph (ww, wr, rw edges) without trusting the database, and any cycle is an anomaly, classified by its edges.
Write a list-append workload: each transaction appends a unique integer to one of a few list rows and reads another list. Run it against your database at READ COMMITTED, REPEATABLE READ and SERIALIZABLE with 8 concurrent clients for a few minutes. Reconstruct the dependency graph offline (each list's final order gives the write order) and search for cycles. If you can run the real tool, use Jepsen's Elle on the recorded history.
The script runs 30 seconds per level against PostgreSQL, with each transaction appending to one of four lists and reading two others, and classifies each cyclic group by its weakest cycle: G0/G1c (no rw edge), G-single (one rw edge, read skew) or G2 (more, write skew).
The question. Which anomalies does each isolation level let through on PostgreSQL, and does Serializable produce none?
Predict
Expected: at lower levels you will find cycles that you can classify (G-single for read skew, G2 for write skew), and SERIALIZABLE should produce none (or abort instead).
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
run-an-elle-style-check-on.py # /// script # requires-python = ">=3.10" # dependencies = ["psycopg[binary]"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Run an Elle-style check on your database. A list-append workload: each transaction appends a unique integer to one list and reads two others. Run at Read Committed, Repeatable Read and Serializable with 8 clients, then rebuild the dependency graph from the history (each list's final order is its write order) and look for cycles. Needs the PostgreSQL service from the compose file (port 55462). """ import argparse, itertools, json, random, threading, time from collections import defaultdict import psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55462/postgres", help="Connection string.") p.add_argument("--clients", type=int, default=8, help="Concurrent clients.") p.add_argument("--seconds", type=float, default=30, help="Seconds of workload per isolation level.") p.add_argument("--lists", type=int, default=4, help="Number of list rows.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() counter = itertools.count(1) def workload(level): admin = psycopg.connect(args.dsn, autocommit=True) admin.execute("DROP TABLE IF EXISTS lists") admin.execute("CREATE TABLE lists (k int PRIMARY KEY, vals int[] NOT NULL DEFAULT '{}')") admin.execute(f"INSERT INTO lists SELECT k, '{{}}' FROM generate_series(1, {args.lists}) k") history, aborted, stop = [], [0], threading.Event() lock = threading.Lock() def client(cid): rnd = random.Random(args.seed * 100 + cid) con = psycopg.connect(args.dsn) while not stop.is_set(): k = rnd.randint(1, args.lists); v = next(counter) reads = rnd.sample([x for x in range(1, args.lists + 1) if x != k], 2) try: con.execute(f"BEGIN ISOLATION LEVEL {level}") con.execute("UPDATE lists SET vals = array_append(vals, %s) WHERE k = %s", (v, k)) got = {r: con.execute("SELECT vals FROM lists WHERE k = %s", (r,)).fetchone()[0] for r in reads} con.commit() with lock: history.append({"append": (k, v), "reads": got}) except psycopg.errors.SerializationFailure: con.rollback() with lock: aborted[0] += 1 con.close() ts = [threading.Thread(target=client, args=(c,)) for c in range(args.clients)] for t in ts: t.start() time.sleep(args.seconds); stop.set() for t in ts: t.join() final = {k: vals for k, vals in admin.execute("SELECT k, vals FROM lists").fetchall()} admin.close() return history, aborted[0], final def check(history, final): """Edges from the list-append history (Elle's inference), then cycles classified by their rw edges.""" writer = {h["append"][1]: i for i, h in enumerate(history)} succ = defaultdict(set); kind = {} def edge(a, b, k): if a != b and a is not None and b is not None: succ[a].add(b); kind.setdefault((a, b), set()).add(k) for k, order in final.items(): for x, y in zip(order, order[1:]): edge(writer.get(x), writer.get(y), "ww") bad_reads = 0 for i, h in enumerate(history): for k, seen in h["reads"].items(): order = final[k] if seen != order[: len(seen)]: bad_reads += 1; continue # not a prefix: an impossible read if seen: edge(writer.get(seen[-1]), i, "wr") if len(seen) < len(order): edge(i, writer.get(order[len(seen)]), "rw") # strongly connected components (iterative Tarjan) index, low, on, stack, sccs, n = {}, {}, set(), [], [], [0] for root in range(len(history)): if root in index: continue work = [(root, iter(succ[root]))]; index[root] = low[root] = n[0]; n[0] += 1; stack.append(root); on.add(root) while work: v, it = work[-1] w = next(it, None) if w is None: work.pop() if work: low[work[-1][0]] = min(low[work[-1][0]], low[v]) if low[v] == index[v]: comp = [] while True: x = stack.pop(); on.discard(x); comp.append(x) if x == v: break if len(comp) > 1: sccs.append(set(comp)) elif w not in index: index[w] = low[w] = n[0]; n[0] += 1; stack.append(w); on.add(w); work.append((w, iter(succ[w]))) elif w in on: low[v] = min(low[v], index[w]) # classify: a cycle with no rw edge (G0/G1c), exactly one (G-single, read skew), or more (G2, write skew) counts = {"G0/G1c": 0, "G-single": 0, "G2": 0} for comp in sccs: dep = {a: {b for b in succ[a] if b in comp and kind[(a, b)] - {"rw"}} for a in comp} def reach(src, dst): seen, todo = {src}, [src] while todo: x = todo.pop() if x == dst: return True for y in dep[x]: if y not in seen: seen.add(y); todo.append(y) return False if any(reach(b, a) for a in comp for b in dep[a]): counts["G0/G1c"] += 1 elif any(reach(b, a) for a in comp for b in succ[a] if b in comp and "rw" in kind[(a, b)]): counts["G-single"] += 1 else: counts["G2"] += 1 return counts, len(sccs), bad_reads rows = [] for level in ("READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"): history, aborted, final = workload(level) counts, n_scc, bad = check(history, final) rows.append({"level": level, "committed": len(history), "aborted": aborted, "cyclic_components": n_scc, **counts, "non_prefix_reads": bad}) print(f"{level:<16} {len(history):>6} committed, {aborted:>5} aborted cycles: G-single {counts['G-single']:>4}, " f"G2 {counts['G2']:>4}, G0/G1c {counts['G0/G1c']} impossible reads {bad}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"levels": 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
run-an-elle-style-check-on.compose.yml # PostgreSQL for this lab. services: postgres: image: postgres:17.6 environment: POSTGRES_PASSWORD: lab ports: ["55462:5432"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 1s retries: 304Start 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 run-an-elle-style-check-on.compose.yml up -d --wait uv run run-an-elle-style-check-on.py uv run run-an-elle-style-check-on.py --help # every knob docker compose -f run-an-elle-style-check-on.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:55462/postgres | Connection string. |
| --clients8 | Concurrent clients. |
| --seconds30 | Seconds of workload per isolation level. |
| --lists4 | Number of list rows. |
| --seed0 | Random seed. |
Our run
List-append workload, 8 clients
| Level | Committed | Aborted | G-single | G2 | G0/G1c | Impossible reads |
|---|---|---|---|---|---|---|
| READ COMMITTED | 27.5K | 0 | 0 | 4,710 | 0 | 0 |
| REPEATABLE READ | 16.6K | 22.1K | 0 | 2,540 | 0 | 0 |
| SERIALIZABLE | 12.5K | 44.3K | 0 | 0 | 0 | 0 |
READ COMMITTED 27521 committed, 0 aborted cycles: G-single 0, G2 4708, G0/G1c 0 impossible reads 0
REPEATABLE READ 16574 committed, 22146 aborted cycles: G-single 0, G2 2543, G0/G1c 0 impossible reads 0
SERIALIZABLE 12502 committed, 44334 aborted cycles: G-single 0, G2 0, G0/G1c 0 impossible reads 0What we found
At Read Committed the checker found 4,710 groups of transactions forming G2 cycles, write skew: two transactions each appended to one list and read the other's list before the other's append was visible. At Repeatable Read it found 2,540, fewer only because PostgreSQL aborted 22.1K transactions whose appends conflicted on the same row; snapshot isolation does not prevent write skew. At Serializable it found none (0), as expected, and aborted 44.3K transactions instead, more than it committed. No cycle had exactly one anti-dependency (G-single, read skew) at any level: with only two reads per transaction, issued right after the append, this workload rarely gives read skew a chance, so its absence here is not evidence that Read Committed prevents it.
Read it
The point: the vendor's level name is a hypothesis you can test cheaply.
The classification follows the dependency-graph vocabulary of the count-the-shapes lab: a cycle with no anti-dependency is G0/G1c, with exactly one is G-single, with more is G2. This checker is a small version of Elle's inference; the real tool also checks for aborted reads, intermediate reads and impossible orders, and it finds anomalies in databases whose documentation promises they cannot happen.