skip to content

A routine holds a reference to one row of an in-memory table, appends more rows, then reads through that reference. What can go wrong?

level: seniorimportance: should knowfreq 52%

answer

  1. the owner is alive, the storage is not
  2. capacity exceeded means a new block
  3. copy, then release the old block
  4. tests stay under the initial capacity
  5. end the borrow before the mutation

basics

~20 s

If the table keeps its rows in one contiguous block, an append past its capacity allocates a larger block, copies the rows and releases the old one. Any reference taken earlier now names released storage, so the later read is meaningless.

solid answer

~50 s

The owner is still alive, so this looks safe — but the *storage* moved. A growable array-backed table holds its rows in one block with spare capacity; once an append exceeds that capacity it allocates a larger block, copies, and releases the old one. References taken earlier point into the old block, which the allocator has reclaimed and may already have handed to something else. The symptom is nasty: small inputs never exceed the initial capacity, so tests pass and production corrupts. The fix is structural, not defensive — end the borrow before the mutation begins. Copy out the one field you needed, or re-resolve the row by index or key *after* the append. Note that the hazard depends on the container: a node-based structure that never moves its elements does not have it, which is why the rule is stated over borrows rather than over one data structure.

code

pseudocode · 3 lines
pseudocode
row = table.row_at(5)       // a borrow into the table's current block
table.append(new_row)       // capacity exceeded: allocate bigger, copy, release old
print(row.name)             // reads the released block: contents undefined

go deeper

for a junior

Remember the shape rather than the details: a reference into a growable collection must not be kept across anything that adds to that collection. Read what you need first.

for a middle

Explain the reallocation step by step — larger block, copy, release the old — and say why the reference into the old block is now meaningless even though the table is fine.

for a senior

Show how you would catch it before production: why small inputs pass, why the failure is intermittent, and which of copy-out, re-resolve or two-phase restructuring you would apply here.

for a principal

Decide where the guard belongs across a codebase: a checked discipline, a modification counter in your own container types, or API shapes that never hand out interior references at all. Each has a different cost and a different blast radius.

## What actually happens on the append A growable table backed by contiguous storage keeps two numbers: how many rows it holds and how many it has room for. Appending within the spare room is a write past the last row. Appending when the room is used up is a different operation entirely: 1. Allocate a larger block, typically a multiple of the current capacity. 2. Copy or move every existing row into it. 3. Release the old block back to the allocator. 4. Point the table at the new block. Steps 2 and 3 are what break the reference. A reference taken before the append names an address inside the *old* block. After step 3 that address belongs to the allocator again — it may still contain the old bytes, it may have been handed to the next allocation, or it may not be mapped at all. Reading through it afterwards is not "reading a stale value"; it is reading storage with no defined contents. Notice what has **not** gone wrong: the table itself is perfectly alive and well-formed, and its owner has not been released. That is what makes this a favourite whiteboard trap — the usual "did the owner die?" reasoning finds nothing. ## Why it survives the test suite - **Small inputs stay inside the initial capacity**, so no reallocation happens and the stale reference is never exercised. - **Reallocation is amortised**, so it fires on a minority of appends — the failing run may be one in dozens. - **The released block is often still intact** immediately afterwards, so the read returns the right answer by luck until allocation pressure rises. The result is a defect whose reproduction depends on data size and allocator behaviour, which is exactly the profile of a bug that ships. ## The hazard is not universal — which container matters | Storage shape | Does an insert move existing elements? | References into elements | |---|---|---| | Contiguous, grow-by-reallocate | Yes, once capacity is exceeded | Invalidated by the growth | | Node-based, one allocation per element | No | Survive insertion elsewhere | | Contiguous, with a stable-index indirection layer | Elements may move; the index does not | Use the index, not the address | This is why the discipline states the rule over borrows and mutation rather than over any one data structure: a checker that forbids holding a borrow into a container across a mutating call is right in every row of that table, without knowing which row applies. ## Restructuring so the borrow ends first The reflex fix — take the reference again after the append — is correct but often unnecessary. Three shapes, in rough order of preference: 1. **Copy out what you needed.** If the borrow existed only to read one small field, copy that field and let the borrow end on the same line. The mutation then begins with nothing borrowed. 2. **Re-resolve after the mutation.** Keep the row's index or key, not its address, and ask the table for the row again afterwards. Costs one lookup and is immune to any amount of growth. 3. **Split into phases.** Walk the table and collect the edits you want into a separate list, then apply them all once the walk is over. This is the right shape when the loop both reads and appends, because no borrow is live while the table changes. A fourth option — reserving capacity up front so no reallocation occurs — is a performance technique, not a correctness fix. It makes the bug rarer, which is the worst possible outcome for a bug of this kind. ## Where the enforcement sits Ecosystems differ. Where the aliasing rule is checked at compile time, this program simply does not build: the append needs an exclusive borrow of the table while a borrow into the table is still live. Where it is not checked, the same code compiles and the defect is found in production, sometimes years later. Some libraries add a cheap runtime guard — a modification counter that makes an outstanding cursor fail loudly on the next use — which converts silent corruption into a clear error without proving anything in advance. All three are answers to the same question: *may a reference into a container outlive a mutation of that container?* The answer is always no; only the moment you are told differs.

  • Does reserving enough capacity up front fix this?
    No — it hides it. Reserving makes reallocation unlikely for the sizes you predicted, so the stale read stops firing in testing while the code remains wrong for any input that exceeds the reservation. Treat it as a throughput tweak and fix the borrow separately.
  • Which containers do not have this hazard, and why does the rule not carve them out?
    Structures that allocate each element separately never move existing elements, so references into them survive insertions elsewhere. The rule is still stated over borrows because it must hold for every container a program uses, and because removal still releases the element you were pointing at.
  • How would you catch this in a codebase where nothing is checked at compile time?
    Make the container detect it: bump a modification counter on every structural change and have cursors and views check it on use, so the stale access fails loudly at the point of misuse. Then keep review pressure on the shape itself, since a loop that reads and mutates the same container is the pattern that produces it.

You note a friend's seat number in a small hall, then the show moves to a bigger one and the old hall is locked up. The number is still perfectly legible, and reading it there tells you nothing about where anyone is sitting now.

saying these in an interview costs you the question

  • Says the reference is fine because the table itself is still alive
  • Believes references are updated to follow the elements when storage moves
  • Assumes an append never touches the existing rows
  • Thinks reserving capacity up front makes the code correct
  • Calls it a concurrency bug when only one thread is running
  • Expects a crash every time rather than intermittent corruption