What is a phantom read in a database transaction, and how is it different from a non-repeatable read?
answer
- Set membership changes, not values
- Can't lock a row that doesn't exist
- INSERT, DELETE, or update across the boundary
- REPEATABLE READ allows it, SERIALIZABLE doesn't
- Aggregate vs detail mismatch
basics
~20 sA phantom read is when a transaction re-runs the same search condition and the set of matching rows changes, because another transaction committed inserts or deletes. A non-repeatable read is when a row you already read comes back with different values.
solid answer
~50 sBoth are re-read anomalies; they differ in *what* changes. - **Non-repeatable read**: you read row 42, someone commits an update to it, you read it again and a value differs. The membership of your result set is stable. - **Phantom read**: you evaluate a predicate such as `status = 'PENDING'` and get 10 rows; another transaction commits an INSERT (or DELETE, or an update that moves a row into or out of the predicate); you re-run the same query and get 11. The new row is the phantom. The distinction drives the fix. To stop non-repeatable reads you can hold a lock on, or a snapshot of, the rows you already saw. You cannot lock a row that does not exist yet, so stopping phantoms needs a lock on the *predicate* — a range of key space including the gaps — or one consistent snapshot for the whole transaction. That is why the SQL standard permits phantoms at REPEATABLE READ and excludes them only at SERIALIZABLE.
code
sql · 13 lines-- Transaction A
BEGIN;
SELECT count(*), sum(total) FROM orders WHERE status = 'PENDING';
-- 5 | 5000
-- -- Transaction B (concurrent)
-- BEGIN;
-- INSERT INTO orders(status, total) VALUES ('PENDING', 700);
-- COMMIT;
SELECT count(*), sum(total) FROM orders WHERE status = 'PENDING';
-- 6 | 5700 <- phantom row
COMMIT;go deeper
Give the definition crisply — the row set for the same condition changes because someone committed an insert or delete — and contrast it with a non-repeatable read (same row, different value). One example is enough.
Add that DELETEs and boundary-crossing UPDATEs also count, and explain why the mechanism differs: you cannot lock a nonexistent row, so prevention needs range/predicate locking or a consistent snapshot.
Tie it to real failure modes — read-check-then-insert, aggregate/detail mismatch, pagination — and name concrete prevention: single-statement reads, snapshot reads, SERIALIZABLE, or a unique/exclusion constraint that enforces the rule in the engine.
Frame it as a class of correctness argument: any invariant that depends on 'no other row matches this predicate' needs a mechanism covering rows that do not exist yet. Discuss the concurrency cost of each option and where the invariant should live.
## The definition A **predicate** is the search condition of a query: `WHERE status = 'PENDING'`, `WHERE room_id = 7 AND day = '2026-05-01'`, `WHERE amount > 1000`. A **phantom read** happens when one transaction evaluates a predicate, later evaluates the same predicate again, and the *set of rows that satisfy it* has changed because another transaction committed in between. The rows that appear (or vanish) are the phantoms. Three things can produce a phantom, not just INSERT: - an INSERT of a row that matches the predicate, - a DELETE of a row that matched it, - an UPDATE that moves a row across the predicate boundary (an order whose `amount` goes from 900 to 1100 becomes a phantom for `amount > 1000`). ## A worked example Transaction A is producing an invoice for pending orders: 1. A: `SELECT SUM(total) FROM orders WHERE status = 'PENDING'` → 5,000. 2. B: inserts a new pending order for 700 and commits. 3. A: `SELECT count(*) , SUM(total) FROM orders WHERE status = 'PENDING'` → 6 rows, 5,700. A's own invoice now contains an internally inconsistent total and line-item list. Nothing A read was *stale* in the dirty-read sense — every value B wrote was committed. The problem is that A saw two different worlds within one transaction. ## Why it is a separate category from a non-repeatable read A non-repeatable read is *value instability on a known row identity*. A phantom is *membership instability of a set*. The reason the standard bothers to separate them is entirely about the prevention mechanism: - Value instability is fixable by remembering identities: hold a shared lock on each row you read until commit, or serve all reads from a snapshot taken at transaction start. - Membership instability is not fixable that way, because the offending row has no identity yet at the time you would need to lock it. A lock manager can only lock things that exist. Preventing phantoms therefore requires either **predicate locking** (lock the logical condition), its practical approximation **index-range / next-key / gap locking** (lock a contiguous stretch of index key space, including the empty gaps between existing keys), or a **consistent snapshot** so re-reads never observe later commits at all. ## Where the standard puts it The classic ANSI/ISO SQL isolation table defines levels by which of three phenomena they permit: | Level | Dirty read | Non-repeatable read | Phantom | |---|---|---|---| | READ UNCOMMITTED | yes | yes | yes | | READ COMMITTED | no | yes | yes | | REPEATABLE READ | no | no | yes | | SERIALIZABLE | no | no | no | Note the shape of the table: REPEATABLE READ exists precisely as "row-stable but not set-stable". SERIALIZABLE is the only standard level that forbids phantoms. ## Where it actually bites in production - **Aggregate vs. detail mismatch**: a report that runs `COUNT(*)` and then fetches the rows and finds a different number. - **Read-check-then-write**: "is there already a row for this key/range? no → insert" is unsafe under phantoms because the check and the insert straddle another transaction's commit. This is why uniqueness is enforced by a unique index rather than by a SELECT. - **Pagination and cursors**: rows appearing or disappearing between pages of the same logical scan. - **Batch jobs** that scan a queue predicate twice (claim, then process) and are surprised by a changed working set. ## Practical prevention, in order of preference 1. Read everything you need in **one statement** — a single statement is atomic with respect to other transactions on essentially every engine, so no phantom can slip in mid-predicate. 2. Run the transaction at an isolation level whose reads come from **one snapshot**, so re-reads are stable. 3. If the transaction must *act* on the absence of rows, snapshot stability is not enough — use SERIALIZABLE, explicit range locking, or a database constraint that enforces the rule directly. The general lesson: phantoms are about *sets*, and any correctness argument that depends on "nothing else matches my predicate" needs a mechanism that covers rows that do not exist yet.
- Can a DELETE cause a phantom read, or only an INSERT?A DELETE can too. The anomaly is defined as the result set of a predicate changing between evaluations inside one transaction, so a committed DELETE that removes a matching row is a phantom just as much as an INSERT that adds one. An UPDATE also counts when it moves a row across the predicate boundary — for example changing an order's status so it starts or stops matching `status = 'PENDING'`.
- Why can't the engine just take a shared lock on every row it returns and be done with it?Because the phantom row is not in the result set at lock time — it does not exist yet, or does not yet match. A lock manager can only lock objects that exist, so locking the returned rows leaves the gaps between them unprotected. Preventing phantoms requires locking the predicate itself, approximated in practice by locking ranges of index key space including the empty gaps.
Non-repeatable read is a guest at the party changing clothes between your two headcounts. A phantom read is a new guest walking in. You can watch the guests you already met; you can't watch someone who is not there yet.
saying these in an interview costs you the question
- Saying a phantom read is just a non-repeatable read on a different row
- Claiming only INSERTs cause phantoms, never DELETEs or boundary-crossing UPDATEs
- Confusing it with a dirty read — phantoms come from committed data, not uncommitted
- Believing READ COMMITTED prevents phantoms because it hides uncommitted data
- Thinking locking all rows returned by the query is enough