2026-06-14
You set your transaction isolation to REPEATABLE READ thinking you're safe. You run the same query twice in one transaction and get different row counts. What happened? A phantom read: another transaction inserted (or deleted) rows that match your WHERE clause between your two reads. Repeatable Read protects rows you've already seen from being modified — it doesn't stop new rows from appearing.
The SQL standard defines four isolation levels by which anomalies they prevent:
Non-repeatable read vs phantom read: non-repeatable is the same row changing between reads (UPDATE). Phantom is the set of matching rows changing (INSERT/DELETE).
Real-world example. A bank runs end-of-day interest calculation:
SELECT SUM(balance) FROM accounts WHERE branch = 'NYC' → $10MSELECT COUNT(*) FROM accounts WHERE branch = 'NYC' → count includes the new rowUnder standard Repeatable Read, this is legal. The fix depends on your database:
SELECT ... FOR UPDATE).Rule of thumb. If your transaction makes a decision based on the absence of rows ("no account exists with this email, so insert one") or aggregates over a set ("sum all matching rows"), Repeatable Read is not enough. Use Serializable, or enforce the invariant with a unique constraint, advisory lock, or SELECT ... FOR UPDATE on a sentinel row.
Cost trade-off. Serializable on Postgres uses SSI (Serializable Snapshot Isolation), which adds ~10-30% overhead and can abort transactions with serialization failures. Your code must retry. Read Committed is cheap but lets anomalies through. Pick per-transaction, not per-database.
