Skip to content

Step through the failure

Press Next to advance one statement at a time. The score shows who did what and when; the panels below show what the engine held at that exact moment. Change the engine or the isolation level and the same steps re-run instantly.

What to run

01234567T1committedT2committedb1b2r1[P:1..2]{1,2}r2[P:1..2]{1,2}w1[1=0]w2[2=0]c1c2anomaly
Key
1 Dr A
2 Dr B
Value
0 off call
1 on call

Step 0, T1: begin.

Step through itStep 0 of 7

Nothing has gone wrong yet

PostgreSQL 16 · READ COMMITTED

Nothing in the published definitions has happened up to this step. Keep stepping — an anomaly is something that becomes true at a particular moment, and the mark will appear on the step where it does.

How do I read this?

How to read the score

Each horizontal line is one transaction — one database session, issuing its statements left to right. Everything in the same vertical column happened at the same point in the run, so reading straight down tells you what the two sessions were doing to each other.

One line per transaction
Each transaction gets its own colour and its own marker shape, so the two never depend on colour alone to be told apart.
Hollow marker — a read
The value it saw is printed above the marker. That value is the whole story in most anomalies.
Filled marker — a write
An insert, update or delete. The row it touched is named underneath.
One bar line — commit
The transaction finished and its work became permanent and visible to everyone.
Two bar lines — rollback
The transaction was undone, either because it asked to be or because the engine killed it.
Dashed arc — a wait
The statement could not proceed and blocked on a lock. The arc lands on the step that finally released it.
Bracket under a stave — how long a view is frozen
The span of that transaction’s snapshot: taken once, at the step it starts on, and used for every read until the transaction ends. Levels that take a fresh snapshot for each statement have no span to draw, which is the difference between REPEATABLE READ and READ COMMITTED in one picture.
Dashed ring on the bracket — a commit you cannot see
Another transaction committed inside your span. It succeeded and it is in the table, and this transaction keeps reading the older version anyway — not a bug, but the promise the level made.
Solid line with a triangle — where you are
The step currently shown in the panels below. Stepping moves it.
Red bracket — the moment it went wrong
It sits above the step where the anomaly became unavoidable. Red is used for nothing else on this site.
The labels, e.g. w1[x]
Standard schedule shorthand: r is a read, w a write, c a commit, a an abort. The digit is the transaction and the letter in brackets is the row.

Drag a mark sideways to re-interleave and re-run. A mark can only move between its own transaction’s neighbouring operations — a session issues its statements in order, so the interleaving is the only thing you get to choose. The left and right arrow keys step too.

Inside the engine at this step

The state of the database at the exact statement above — not the end of the run. Step backwards and forwards to watch these change.

Version chains

Every write creates a version rather than replacing one. xmin is the transaction that created it, xmax the transaction that superseded or deleted it.

The test: you see a version if the transaction in xmin had committed when your snapshot was taken, and xmax is either empty or names a transaction that had not. Walk the versions newest first and take the first one that passes.

The rule, in the vendor’s words
When a transaction uses this isolation level, a SELECT query (without a FOR UPDATE/SHARE clause) sees only data committed before the query began; it never sees either uncommitted data or changes committed by concurrent transactions during the query's execution. In effect, a SELECT query sees a snapshot of the database as of the instant the query begins to run.
Key1
ValuexminxmaxState of this version
1initiallive
Key2
ValuexminxmaxState of this version
1initiallive

Locks held

A record lock is on a row. A gap lock is on the space between rows, and it exists to stop an insert appearing where a reader has already looked.

Two transactions cannot hold conflicting locks on the same row, so the second statement waits until the first transaction ends. Waiting is not an error: the statement completes later, against whatever the row has become.

The rule, in the vendor’s words
In this case, the would-be updater will wait for the first updating transaction to commit or roll back (if it is still in progress). If the first updater rolls back, then its effects are negated and the second updater can proceed with updating the originally found row. If the first updater commits, the second updater will ignore the row if the first updater deleted it, otherwise it will attempt to apply its operation to the updated version of the row.

No locks held at this step.

Snapshots

Which transactions had committed when each snapshot was taken.

No snapshot taken yet.

Committed table

1=1 2=1

Every ordering of these statements

Each session issues its own statements in order, so the only freedom is how the two interleave — 70 orderings in total. Every one of them was run at every level of this engine.

  • READ UNCOMMITTED36 / 70write-skew
  • READ COMMITTED36 / 70write-skew
  • REPEATABLE READ36 / 70write-skew
  • SNAPSHOTno such level
  • SERIALIZABLE0 / 70no anomaly in any ordering36 aborted instead

These are counts of orderings, not likelihoods. Real interleavings are not evenly distributed, and nothing here says how often anything happens under load — only what is possible at all.

What the real engine did

These exact statements were run against this engine in a container, and what it did was written down. Not a re-enactment — the recording below is the evidence the model is built to match.

16.14recorded 2026-08-06postgres:16-alpine

These exact statements were run against this engine in a container, and what it did was written down. Not a re-enactment — the recording below is the evidence the model is built to match.
StepThe engine returnedThis model says
0 b1okok
1 b2okok
2 r1[P:1..2]{1=1, 2=1}{1=1, 2=1}
3 r2[P:1..2]{1=1, 2=1}{1=1, 2=1}
4 w1[1=0]okok
5 w2[2=0]okok
6 c1okok
7 c2okok

The two columns agree because the build fails otherwise — pnpm test:oracle compares every recorded run against the model field by field, and a disagreement is the model’s to fix, never the recording’s.

The same schedule at the other levels

Every level of this engine, run on these exact statements, with the first step at which it stops agreeing with the run above. Pick one to switch to it.

Not conflict-serializable

T1 must run before T2, and T2 must run before T1 — which is impossible, so no order of these transactions one after another produces this outcome.

The on-call roster empties

The situation: Two doctors are on call and at least one must remain. Each opens the roster, sees that the other is on call, and takes themselves off. Both commit.

What it teaches: This is the anomaly that is not in the ANSI list. PostgreSQL’s REPEATABLE READ is snapshot isolation, and permits it: both transactions commit and nobody is on call. Only SERIALIZABLE catches it, and it does so by aborting the second transaction to commit with a read/write dependency failure — not by blocking. MySQL InnoDB permits it at REPEATABLE READ as well, and at SERIALIZABLE it does not detect anything — it deadlocks, and one transaction is rolled back with 1213. SQL Server permits it at SNAPSHOT, which is the same anomaly under a name that at least admits what the level is; at REPEATABLE READ and SERIALIZABLE its shared locks turn the schedule into a deadlock, and because SQL Server picks its victim by cost estimate this model refuses to say which transaction loses. And Oracle settles the argument: it permits this at the level called SERIALIZABLE, because Oracle’s SERIALIZABLE is snapshot isolation. Both doctors go off call, both commit, and no error is raised — at the strongest level name the standard has.