skip to content

A transaction is guaranteed that any row it re-reads still shows the values it saw earlier. Why is that guarantee still not enough for a query that aggregates a range of rows, and what stronger guarantee would be needed?

level: middleimportance: should knowfreq 52%

answer

  1. per-row stability ≠ per-predicate stability
  2. insert/delete/move-into-range changes the set
  3. aggregates and existence checks are set questions
  4. predicate locks, transaction-wide snapshot, or serializable
  5. cheaper: one statement, or a constraint that enforces the invariant

basics

~20 s

The guarantee covers rows that already exist and that you already read. It says nothing about rows another transaction inserts into, or deletes from, the range — so a repeated range query can return a different set of rows even though every individual row is stable.

solid answer

~50 s

Stability of a re-read is a **per-row** guarantee: the rows you touched will not change value under you. A range query such as `SELECT SUM(amount) FROM orders WHERE created_at >= :d` does not ask about specific rows; it asks about *everything matching a predicate*. A concurrent transaction can commit a new row that satisfies the predicate — a row you never read, so no per-row guarantee applies to it — and a repeated run of the query returns a different total. Blocking that requires a guarantee over the **predicate**, not the rows: either range/predicate locking (locking the gaps where a matching row could be inserted) or a transaction-wide snapshot that fixes the entire visible dataset at one instant, or a serializable level that detects the conflicting insert. The practical takeaways: the per-row guarantee is enough for check-then-act on known rows and not enough for aggregates or existence checks ("no booking overlaps this slot"), and level names differ across engines, so verify what yours actually forbids.

code

sql · 5 lines
sql
SELECT count(*) FROM bookings
 WHERE room_id = 7 AND slot = '2026-08-12T10:00';   -- 0, so it looks free

INSERT INTO bookings(room_id, slot) VALUES (7, '2026-08-12T10:00');
-- two transactions can both see 0 and both insert

go deeper

for a junior

Say that the guarantee applies to rows you already read, while a range query can pick up rows that did not exist before.

for a middle

Explain the set-versus-row distinction, list inserts, deletes and predicate-moving updates as causes, and name predicate locking, snapshots and serializable as remedies.

for a senior

Emphasize the read-then-write case, why a snapshot is insufficient there, and prefer constraints or parent-row locks over isolation levels.

for a principal

Decide where invariants are enforced — schema constraints versus isolation — and standardize that choice so correctness does not depend on each caller's session settings.

## Two different questions a transaction can ask 1. **"What is the value of *this* row?"** — identified by primary key or already fetched. Stability here means: re-reading that row returns the same values. 2. **"Which rows match *this predicate*, and what do they sum to?"** — a range, a filter, an existence check, a `COUNT`. The answer is a *set*, and the set's membership is not pinned by any statement about the rows you have already seen. A guarantee that re-reads of known rows are stable answers only the first question. That is the boundary this question is about. ## Why the range query still moves Consider a transaction that does: ```sql SELECT SUM(amount) FROM orders WHERE region = 'EU'; -- 10,000 -- ... other work ... SELECT SUM(amount) FROM orders WHERE region = 'EU'; -- 10,400 ``` Between the two, another transaction inserted an EU order of 400 and committed. Every row the first query read is unchanged — the per-row guarantee is fully honoured. The *new* row was never read, so nothing protected it from appearing. Deletes do the same in reverse, and an update that moves a row *into* the predicate (`region` changed from `US` to `EU`) has the same effect: it is a row that was not in your result set and now is. The general statement: per-row stability constrains **existing, already-read rows**. Set membership is constrained by nothing unless the engine takes a guarantee over the predicate itself. ## Where this bites in real code - **Aggregates that must tie out**: a report whose subtotals and grand total are computed by separate queries. - **Existence checks used to authorize an action**: "there is no overlapping booking for this room, therefore I may insert one." Two transactions each check, each find nothing, and each insert. The check was stable; the world was not. - **Capacity checks**: "count of active sessions is 9, limit is 10, so I may add one." - **Re-scanning a range in a loop** while other writers are active. Note the pattern in the last three: the transaction reads a predicate and then *writes* something that would have changed the predicate's answer. That is what makes it dangerous rather than merely surprising. ## What a stronger guarantee looks like Three mechanisms, with different costs: **Predicate / range locking.** A lock-based engine can lock not just the rows that exist but the *gaps between them* in an index range, so any insert that would fall inside the range must wait. This makes the predicate's answer stable. It is precise but expensive: it blocks writers whose rows you never see, and it needs a suitable index or it degrades toward locking far more than intended. **A transaction-wide snapshot.** If every read is answered from one fixed instant, then a row committed after that instant is simply invisible, whether it is a row you read before or a brand-new one. Repeating the aggregate returns the same total. This makes *reads* stable at no blocking cost — which is why many multiversion engines' REPEATABLE READ is stronger than the standard requires. But it does not make *writes* safe: your snapshot hides the concurrent insert, so a decision made from the snapshot can still be wrong at commit time. Two transactions can each see "no overlapping booking" in their own snapshot and both insert. **Serializable.** The engine guarantees the outcome equals *some* serial order, by locking predicates or by tracking read–write dependencies and aborting a transaction whose reads have been invalidated. This is the only level that makes the read-then-write pattern safe without you writing extra code — at the cost of aborts to retry and bookkeeping overhead. ## The application-level alternatives Often cheaper than raising the level: - **Do it in one statement.** A single query is evaluated against one consistent view, so a single `INSERT ... SELECT`, or an aggregate computed once with a common table expression, has no gap to exploit. - **Make the database enforce the invariant.** A unique constraint or an exclusion-style constraint on the range turns the race into a constraint violation you can catch — far more robust than any read-based check, because it does not depend on isolation levels or on every code path behaving. - **Lock a parent row.** Serialize all writers of the predicate through an exclusive lock on the owning entity (the room, the account, the tenant). Simple, correct, and costs throughput only within that entity. ## The interview-ready summary "Stability of a re-read is a promise about rows I already read. Aggregates and existence checks ask about a predicate, and a concurrent insert or delete can change the answer without touching any row I read. To pin the answer I need something that covers the predicate: range locking, a transaction-wide snapshot, or serializable isolation — and if I'm going to *write* based on the check, I'd rather use a constraint or lock the parent row than rely on the isolation level."

  • Does a transaction-wide snapshot make a read-then-insert check safe?
    No. The snapshot makes the check stable for the reader, but it also hides the concurrent insert entirely, so two transactions can each see an empty result and both insert. The snapshot fixes read consistency, not write correctness. Making the write safe requires serializable isolation, a lock on a shared parent row, or a constraint the database enforces at insert time.
  • Why is a unique or exclusion constraint usually better than raising the isolation level for an overlap check?
    A constraint is enforced by the storage engine on every insert regardless of isolation level, code path, or which service performed the write, and it converts the race into a clear violation error. Relying on a read-based check plus an isolation level depends on every writer using the right level and the right query, which no large codebase reliably sustains.

Guaranteeing that no book already on the shelf will be edited says nothing about a librarian adding a new book to the same shelf while you count them.

saying these in an interview costs you the question

  • Assuming stable re-reads also freeze the result set of a range query
  • Believing a transaction-wide snapshot makes a read-then-write existence check correct
  • Thinking only inserts can change the set, forgetting deletes and updates that move a row into or out of the predicate
  • Reaching for a higher isolation level when a unique or exclusion constraint solves the problem outright
  • Assuming every engine's REPEATABLE READ behaves identically here

context