Phantom rows · Aleksei Zhynguel
Interactive noteTransaction isolation · part 6 of 7Sep 2026 · 17 minPostgres 16

Phantom rows

So far every problem involved rows that already existed. This one is about a row that doesn’t exist yet, which makes it the hardest kind to lock.

Scroll to advance. Steps 1–8 are the idea, steps 9–12 go further. Switch the level, and how the check is written, at any step.

{{ outShort }}
{{ aName }} t {{ bName }}
{{ e.seq }}
{{ e.t }}{{ e.s }}
Database · committed
{{ r.name }}{{ r.val }}
{{ v.t }}{{ v.s }}
{{ pnote }}
{{ outLabel }} {{ outVal }}
{{ warmFrom }} {{ warmQ }}

{{ warmFb }}

01 · A room and a list

Meeting room 1 has one booking so far: Carol at 9:00. Transaction A is checking whether 10:00 is free, and will look again later before it finishes.

02 · A looks

A counts the bookings at 10:00 and gets zero.

03 · B books

Meanwhile B inserts a booking for Bob at 10:00 and commits.

04 · A looks again
Predict first · the diagram shows ? {{ aq3 }}
The explanation opens when you answer.

{{ fb3 }}

A runs exactly the same query and now gets 1. Nothing that A read was changed. A new row appeared that matches A’s search.

That’s a phantom.

05 · Snapshots hide phantoms
Predict first · the diagram shows ? {{ aq4 }}
The explanation opens when you answer.

{{ fb4 }}

Under REPEATABLE READ, A’s second count is 0 again. A snapshot doesn’t just freeze the values of rows; it also decides which rows exist. Bob’s booking was created by a transaction that isn’t in A’s snapshot, so for A it isn’t there.

The SQL standard allows phantoms at this level. Postgres doesn’t produce them.

06 · The real danger

Hiding the phantom isn’t the same as being safe. Now Alice and Bob both want 10:00. Each transaction counts, sees zero, and inserts a booking. Both commit, and room 1 has two meetings at once.

This is write skew again, under REPEATABLE READ. The difference is that the rows involved didn’t exist when the checks ran.

07 · Nothing to lock
Predict first · the diagram shows ? {{ aq6 }}
The explanation opens when you answer.

{{ fb6 }}

The fix for write skew in the last note was FOR UPDATE on the check. It’s selected above. The check returned no rows, so it locked no rows, and both bookings still go through.

You can’t lock something that doesn’t exist yet.

08 · SERIALIZABLE

SERIALIZABLE catches it. Postgres remembers that Alice’s transaction searched for bookings at 10:00, not only which rows came back. When Bob’s insert lands inside that search, it records a dependency. With arrows in both directions, Bob’s commit fails.

Deeper

You can stop here and you’ll know what a phantom is and why it’s dangerous. The next four steps are how Postgres locks a search, and what to use instead.

09 · Locking a search

Postgres records those searches as SIRead locks, also called predicate locks. They never block anyone; they only take notes. How much they cover depends on how the query ran.

With an index on the slot column, the lock covers the index pages the search touched: in effect, the range 10:00. With a sequential scan, it covers the whole table.

10 · Why indexes matter here

That makes indexes part of correctness under SERIALIZABLE, not just speed. Without an index, every search locks the entire table, and any insert anywhere conflicts with it.

Bookings for different rooms on different days start failing with serialization errors. Same code, same data, many more retries. How the index is laid out is in Why indexes are fast.

11 · Give the conflict a row

Another fix is to create a row that both transactions must lock. Each starts by locking room 1 itself with SELECT … FROM rooms WHERE id = 1 FOR UPDATE. Bob waits, then counts again under READ COMMITTED and finds Alice’s booking.

Switch to REPEATABLE READ and it breaks: Bob’s snapshot was taken when his first statement started, before the wait, so he still counts zero. At that level the lock only helps if the transaction also updates the room row, which turns Bob’s wait into an error.

12 · Let a constraint decide

The strongest answer doesn’t depend on the level at all. For fixed slots, a unique index on (room, slot) makes the second insert fail.

For time ranges, Postgres has exclusion constraints: EXCLUDE USING gist (room WITH =, during WITH &&) rejects any two rows whose ranges overlap in the same room. It needs the btree_gist extension. The database then enforces the rule for every writer, including the one you haven’t written yet.

Connections

Every idea in the series, and every step where it appears. To review, pick a term, jump to a step, and come back.

{{ kCount }}
{{ ocTerm }}

{{ ocDef }}

Related
  1. {{ r.where }}{{ r.title }}
The whole series in one table
AnomalyRead committedRepeatable readSerializableWhat stops it
Dirty readpreventedpreventedpreventedUncommitted versions are invisible · part 2, step 8
Read skewallowedpreventedpreventedOne snapshot per transaction · part 3, step 7
Lost update (read, then write)allowedone fails · 40001one fails · 40001Refusing to overwrite a newer row · part 4, step 7
Write skewallowedallowedone fails · 40001Tracking what was read (SSI) · part 5, step 6
Phantom readthis noteallowedpreventedpreventedOne snapshot per transaction · part 6, step 5
Write skew on new rowsthis noteallowedallowedone fails · 40001Predicate locks · part 6, step 9
Postgres 16 behavior, as described in chapter 13.2 of its documentation. The SQL standard allows more than this at each level.
Check yourself{{ score }}
{{ q.n }}

{{ q.q }}

{{ q.fb }}

{{ q.model }}

Three things to keep
ABSENCEA check for something that isn’t there has nothing to lock.
PREDICATE LOCKSSERIALIZABLE remembers what you searched for. Indexes make that memory precise.
CONSTRAINTSA unique or exclusion constraint enforces the rule at any level.
← Write skew Found a mistake? Tell me and I'll fix it. Next: Inside serializable →