skip to content

Why do two reads of one row inside a tracked set agree even when the isolation level permits non-repeatable reads?

level: middleimportance: should knowfreq 44%

answer

  1. the second read never happened
  2. instance reuse, not engine guarantee
  3. untracked reads break the illusion
  4. each held object frozen at its own load

basics

~20 s

Because the second read is served from the identity map: it returns the object already held, so the reads agree by instance reuse, not by an engine guarantee. The held object can still be stale.

solid answer

~50 s

The agreement comes from the application layer, not the database. The first read materialised an object into the tracked set's identity map; the second by-key read is answered from that map, and a query's returned row for the same key is reconciled to the same instance. No second set of column values is ever consulted, so nothing *can* differ — even at an isolation level that allows another committed writer to change the row in between. This is instance reuse masquerading as read stability, and the mask is thin: an untracked projection, an aggregate, or a row not yet loaded will show the newer committed value, so one operation holds a mixed-age view. If the requirement is genuine read stability across statements, take it from the transaction — an isolation level or a locking read — because the map only stabilises rows this set happens to hold.

go deeper

for a junior

Hold on to one sentence: reading a row again inside the same tracked set usually does not read anything, so of course the answer matches. Agreement is not evidence that the row is unchanged.

for a middle

Distinguish the two mechanisms cleanly — instance reuse in the layer versus a read view granted by the transaction — and name a read shape that escapes the map and would expose the difference.

for a senior

Spot the misdiagnosis in incident work: 'I re-read it and it is still wrong' usually means nothing was read. Know which objects a decision depends on and refresh exactly those, or take a locking read.

for a principal

Own the boundary: stability the application manufactures is not a correctness guarantee, and building on it is how systems acquire quiet lost updates. Push real invariants down to the transaction and version checks.

## Two different guarantees that look the same There are two unrelated reasons two reads of a row can agree inside one operation: - **The engine's guarantee.** The transaction was given a view of the data in which that row does not change, so a genuinely re-executed read returns the same columns. This is decided by the isolation level in force and belongs to the database. - **The layer's guarantee.** The second read never reached the database, or its returned row was reconciled to an object already held. The values agree because they are literally the same object's fields. An application on top of a tracked set usually experiences the second one and attributes it to the first. Both produce the same observation — a repeated read that does not change — from completely different machinery, with completely different limits. ## Why the layer's version is so convincing Inside one tracked set: - a by-key read of a held object sends no statement at all; - a query's returned row for a held key is reconciled to the held instance, and the freshly read columns for it are normally dropped; - an association resolving to a held row yields the held instance too. So every path back to that row leads to the same instance, and asking again is not really asking. Turn the isolation level down and the observation does not change, which is exactly why the illusion survives casual testing. ## Where the mask slips | Read shape | Stabilised by the map? | What it shows | |---|---|---| | By-key read of a held object | Yes | The held instance's current state | | Query row for a held key | Yes | The held instance, edits included | | Projection or aggregate | No | Whatever the database currently exposes to this transaction | | A row this set has never loaded | No | Freshly read values, of a different age | | Any read after the object is discarded or the set is cleared | No | Freshly read values | The fourth line is the important one: each held object is frozen at *its own* load time, so an operation that loads a parent early and a child late is working with a graph whose parts are different ages. Nothing about the map makes the graph a consistent snapshot; only the transaction's own view can do that. ## Staleness is the same mechanism seen from the other side The property "asking again gives the same answer" and the property "I might be holding an old value" are one property. A long-lived set makes it worse: the longer the set lives, the older its held objects are relative to the database, while every re-read inside it keeps confirming the old values. This is why "I re-read it and it still says the old value" is such a common misdiagnosis — the re-read never happened. ## Getting stability you can rely on 1. **Decide whether you need it at all.** Many operations read once, compute, and write with a version check on the update. That needs no read stability. 2. **If you do, ask the transaction for it.** The isolation level in force, or a locking read of the specific rows, is what actually stops another committed writer from moving the ground under you. The details of what each level permits belong to the transaction-isolation topic. 3. **If you need freshness instead, ask explicitly.** Re-read the object, or discard it so the next read materialises it again. Both cost a statement, and re-reading also drops pending edits on that object. Data-access layers differ in how much of this they expose: some let you request a locking read as part of an ordinary load, others make you write the read yourself. Query builders with no tracked set have neither problem nor cushion — every read returns what the database said at that moment. ## Saying it well Say plainly that the two reads agree because they returned the same object, and that this is not an isolation guarantee. Then give one falsifying example — an untracked projection in the same operation showing a newer value — and finish with where real read stability comes from. That sequence shows you know both mechanisms and know which one is doing the work.

  • How can you demonstrate that the stability is coming from the layer rather than the engine?
    In one operation, read the row as a tracked object, have another connection commit a change to it, then read the same row twice: once by key and once as an untracked projection. The tracked read still shows the old values, the projection shows the new ones. Only one of them consulted the database.
  • Does the identity map make an object graph a consistent snapshot of the database?
    No. Each object is as of the moment it was materialised, so a parent loaded early and children resolved later can reflect different points in time. The map keeps each row consistent with itself, not the graph consistent with one instant. A transaction-level view is what does that.
  • If instance reuse hides changes, why not discard objects aggressively to stay fresh?
    Because discarding drops pending edits and forfeits the one-object rule that makes edits coherent within an operation, and every re-read costs a statement. Freshness is worth requesting for the specific objects a decision depends on, not as a blanket policy.

saying these in an interview costs you the question

  • Claims the identity map provides transaction isolation
  • Says a re-read inside the set proves the row is unchanged
  • Thinks lowering the isolation level would change what the held object shows
  • Believes a loaded object graph is a consistent point-in-time snapshot
  • Treats held staleness and repeatable reads as unrelated effects