skip to content

What is write skew in a database, and how can two transactions that never write the same row still leave the data violating a business rule?

level: middleimportance: must knowfreq 44%

answer

  1. Overlapping reads, disjoint writes
  2. On-call doctors: both leave, zero remain
  3. Invariant spans more rows than any one transaction writes
  4. No write-write conflict, so nothing objects
  5. Signature: aggregate SELECT, branch, write a different row

basics

~20 s

Write skew: two transactions read an overlapping set of rows, each decides its write is safe based on that read, then each writes a different row. Individually valid, together they break an invariant that spans the rows — for example both on-call doctors going off call.

solid answer

~60 s

The canonical example is on-call scheduling with the rule "at least one doctor on call". Alice and Bob are both on call. Two transactions run at the same time. Each reads `SELECT count(*) FROM doctors WHERE on_call = true AND shift = 42`, sees 2, concludes "one may leave", and updates *its own* row to `on_call = false`. Alice's transaction writes the Alice row; Bob's writes the Bob row. No write-write conflict exists, so nothing objects. Both commit, and the shift has zero doctors on call — a state no serial execution of the two transactions could produce, because the second one to run would have seen a count of 1 and refused. The shape to memorize: **overlapping reads, disjoint writes, an invariant that spans more rows than any single transaction touches.** The read that justified the decision became stale between the read and the commit, and because the transactions wrote different rows there was no conflict for the engine to detect. Same-shaped bugs: two withdrawals each valid against a combined-balance rule, two claims on the same limited capacity, two inserts that must be mutually exclusive.

code

sql · 10 lines
sql
-- invariant: at least one doctor on call per shift
-- T1                                     -- T2
BEGIN;                                    BEGIN;
SELECT count(*) FROM doctors              SELECT count(*) FROM doctors
 WHERE shift_id=42 AND on_call;            WHERE shift_id=42 AND on_call;
-- 2                                      -- 2
UPDATE doctors SET on_call=false          UPDATE doctors SET on_call=false
 WHERE name='alice';                       WHERE name='bob';
COMMIT;                                   COMMIT;
-- 0 doctors on call: no serial order produces this

go deeper

for a junior

Learn to state the pattern and one example: both doctors check that two are on call, each takes themselves off, and none is left. Different rows, so nothing conflicts.

for a middle

Name the three ingredients — overlapping reads, disjoint writes, a multi-row invariant — and distinguish it clearly from lost update.

for a senior

Recognize the shape in a design or code review (aggregate read, branch, write a different row) and connect it to why snapshot isolation cannot help and what enforcement actually would.

for a principal

Treat it as an invariant-placement question: where does the rule live, what is its conflict footprint, and which enforcement layer covers the reads that justify each write.

## The anatomy Write skew has three ingredients, and all three are necessary: 1. **An invariant spanning multiple rows.** "At least one doctor on call." "The sum of the two account balances stays non-negative." "No more than N active sessions." No single row expresses the rule. 2. **Overlapping reads.** Each transaction reads the rows the invariant covers, to check whether its action is permitted. 3. **Disjoint writes.** Each transaction then modifies a *different* row (or inserts a different new row). Because the write sets do not intersect, nothing in a conventional write-conflict detector notices anything. Each transaction is individually correct against the state it read. The composite state is one that no serial ordering could have produced, which is exactly the definition of a non-serializable execution. ## The worked example, step by step Table `doctors(name, shift_id, on_call)`, with Alice and Bob both `on_call = true` for shift 42. Rule: a shift must always have at least one doctor on call. ``` T1 (Alice) T2 (Bob) BEGIN; BEGIN; SELECT count(*) FROM doctors WHERE shift_id=42 AND on_call; SELECT count(*) FROM doctors -- 2, so it is safe for me to leave WHERE shift_id=42 AND on_call; -- 2, so it is safe for me to leave UPDATE doctors SET on_call=false WHERE name='alice'; UPDATE doctors SET on_call=false WHERE name='bob'; COMMIT; COMMIT; ``` Result: zero doctors on call. Run them one after another and the second returns 1 and aborts its own decision. So the concurrent result is not equivalent to either serial order. ## Why it is not one of the classic three anomalies The textbook phenomena — dirty read, non-repeatable read, phantom — are all about a *reader* observing something inconsistent. In write skew, neither transaction observes anything inconsistent. Both saw a perfectly valid, committed, stable snapshot. The anomaly is entirely in the *outcome*: the pair of writes taken together contradicts the reads that authorized them. That is why it does not appear in the classic isolation table at all; it was catalogued later, when snapshot isolation became widespread and needed anomalies of its own to be described by. ## Where it appears in real systems - **Booking and capacity**: two requests each check "seats sold < capacity" and each insert a booking. - **Financial rules across accounts**: an overdraft rule defined over the combined balance of a customer's accounts, with each transaction debiting a different account. - **Uniqueness enforced in application code**: two sign-ups each query for an existing username, find none, and each insert their own new row. (Here the overlapping read is over rows that do not exist yet — the phantom-shaped variant of the same bug.) - **State machines with a quorum rule**: two approvers each check "approvals < required" and each add their own approval, or two operators each drain a different node while the rule is "keep two nodes live". - **Meeting-room double booking**: each transaction checks for an overlapping reservation, finds none, and inserts its own. ## How to recognize it in a code review Look for the sequence: a `SELECT` that aggregates or counts over a set, an `if` in application code that branches on that result, and then a write to a row *other than* the ones counted. That triangle is the signature. The tell-tale phrase in a design doc is "we check first, then update" — safe only when the check and the update concern the same row and the engine detects the conflict, and unsafe as soon as the check spans rows the write does not touch. ## The relationship to lost update A lost update also involves a read-then-write, but both transactions write the *same* row, so one write is silently overwritten. Engines detect that: a same-row write conflict is exactly what first-updater-wins or row locking catches. Write skew is the variant that escapes those mechanisms precisely because the writes are disjoint — which is why it needs a fundamentally different defence rather than a stronger row-level one. ## The takeaway sentence Write skew is what happens when a transaction's correctness argument depends on rows it reads but does not write; unless the database is told to treat those reads as part of the conflict footprint, nothing will stop two such arguments from being valid separately and wrong together.

  • How is write skew different from a lost update?
    In a lost update both transactions write the same row, so one transaction's write silently replaces the other's and the engine's same-row conflict detection or row locking can catch it. In write skew the transactions write different rows, so there is no write-write conflict at all; what is lost is the validity of the read that justified each write. That is why row-level defences fix lost updates but not write skew.
  • Why does write skew not appear in the classic ANSI isolation-level table?
    That table defines levels by reader-visible phenomena — dirty read, non-repeatable read, phantom — catalogued from lock-based implementations. In write skew neither transaction observes anything inconsistent; each read a valid committed state. The anomaly lives in the combined outcome, and it was described later, once snapshot isolation was widespread and needed anomalies that its own reader-side guarantees do not cover.

Two people share one umbrella and each checks the sky: 'it's not raining now, and the other one has it if it starts'. Each independently leaves it behind. Both decisions were reasonable given what each saw; together they get soaked.

saying these in an interview costs you the question

  • Describing it as two transactions overwriting the same row — that is a lost update
  • Claiming a stronger read guarantee such as a stable snapshot prevents it
  • Saying the engine should have detected the conflict, when there is no write-write conflict to detect
  • Believing it only affects UPDATEs, not the insert-shaped variant like double booking
  • Assuming an application-level check plus a short transaction makes the race negligible

context