System Design Simple logoSystem Design Simple Follow on YouTube

Free courseModule 2: Consistency and trade-offs

Lesson 8 of 92: Race conditions and strong consistency

Course outline

Two fans click the same seat at the same moment. Who wins?

Race conditions, lost updates and write skew, how PostgreSQL isolation levels, row locks and conditional updates prevent them, and when strong consistency matters.

Video · 60 secondsWatch the short versionSame diagrams and numbers. Tap to play.

In this lesson you will learn

  • What a race condition is, and why check-then-act fails
  • How one conditional UPDATE closes the gap
  • When to use a row lock, Serializable or a unique constraint instead

Builds on Lesson 7: Consistency models: eventual, quorum, linearizable 18 min read + 60 s video Next: Availability in sequence vs parallel

Two fans click the same seat at the same moment. Who wins?: diagrams from the video

Drafted with AI assistance, then checked and edited by a human engineer · How we write

Short answer

Check-then-act leaves a gap in which two transactions both see a row as free. Close it with one conditional UPDATE and branch on the row count: PostgreSQL re-checks the WHERE on the new row version. Use FOR UPDATE when the rule needs app logic, Serializable for rules across rows, a unique constraint as the net, holds that expire on read, and idempotency keys on payment.

Key facts
Core bugCheck-then-act: a decision made on a value that changes before the write
Default fixUPDATE ... WHERE status = 'free' RETURNING; 1 row wins, 0 rows lost
Read CommittedA blocked UPDATE re-evaluates its WHERE on the committed new row version
Repeatable ReadConcurrent update of the same row fails with SQLSTATE 40001; write skew still possible
SerializableSSI with SIRead predicate locks; catches write skew, needs retry on 40001
Holdshold_expires_at compared with the database's now(); expired counts as free on read
ConsistencySeat ownership must be linearizable on the primary; seat maps may be eventual

When two transactions read a row, decide on what they read and then write, the outcome depends on timing, and something that must be exclusive (a seat, a balance, a stock count) can be handed out twice. This article covers what PostgreSQL's isolation levels, row locks and conditional writes do about it, and which consistency model a booking system needs. Interview question: how do you guarantee one seat is sold exactly once when thousands of requests hit it at once?

Prerequisites

Transactions. Statements that commit or roll back as one unit. Atomicity (all or nothing) is not isolation (what you see of transactions running at the same time); most database race conditions live in the isolation part, where every transaction commits cleanly and the combined result is still wrong.

Concurrency. Several app servers with connection pools run requests in parallel against one primary. "At the same moment" means two transactions' lifetimes overlap. Admission control, as in scaling a ticket sale with load balancing and a virtual waiting room, reduces the load but never removes the overlap.

SQLSTATE codes. 40001 serialization failure and 40P01 deadlock mean "retry the whole transaction"; 55P03 is lock not available; 23505 is a unique violation.

Definitions

Definition

Race condition: a correctness bug whose result depends on the timing or interleaving of concurrent operations.

Definition

Data race: two threads access one memory location without synchronisation and at least one writes; a memory-model term (undefined behaviour in C and C++). SQL check-then-act is a race condition with no data race: every statement is synchronised by the database, yet the program is wrong.

Definition

Check-then-act: read a condition, act on it in a later step. Read-modify-write: read a value, compute a new one in the app, write it back.

Definition

Lost update: two transactions write values derived from the same read of a row; the second silently overwrites the first.

Definition

Write skew: two transactions read overlapping rows, each checks a rule that holds, each writes a different row, and together they break the rule.

Definition

Compare-and-set (CAS): write only if the current value still equals the expected one, in one indivisible step, and report whether it happened. A conditional UPDATE ... WHERE with a row count is CAS on a row.

Definition

Idempotency key: a client-chosen unique id for one logical operation, so repeating the request has the effect of sending it once.

How it works: the gap and the atomic update

The naive claim is two statements: SELECT status, then, if the app saw free, UPDATE. The time between the SELECT's snapshot and the UPDATE taking its row lock is the race window; any transaction that reads inside it makes the same decision on the same stale fact.

Two code steps: 1, SELECT owner FROM seats WHERE seat = A12, free?; 2, UPDATE seats SET owner = you; with the words two steps
Check-then-act: the decision and the write are two statements with a window between them.
time (ms)  session 1                          session 2
   0       SELECT status ... -> 'free'
   5                                          SELECT status ... -> 'free'
  20       UPDATE ... SET holder = 'fan-a'
  21       COMMIT
  25                                          UPDATE ... SET holder = 'fan-b'
  26                                          COMMIT

final row: holder = 'fan-b'; both sessions told their user "yours"

Nothing failed: session 2's unconditional UPDATE overwrote session 1, a lost update. Wrapping both statements in BEGIN ... COMMIT changes nothing at the default level, because a plain SELECT takes no lock and the UPDATE does not compare against what was read.

Two side-by-side time lanes: fan A checks and gets free, fan B checks and gets free, then both buy and both show SOLD, stamped SOLD TWICE
Both reads land inside the window, so both writes succeed.

The fix moves the check into the write:

UPDATE seats
SET status = 'held', holder = 'fan-a'
WHERE event_id = 1 AND seat = 'A12' AND status = 'free'
RETURNING seat;

This is safe because of a documented Read Committed rule. The UPDATE finds candidate rows with its statement snapshot. If a candidate was updated by a transaction still in progress, it waits on that row's lock. If the first transaction commits, PostgreSQL takes the updated version of the row and re-evaluates the WHERE clause against it; status = 'free' is now false, the row is skipped and the command reports UPDATE 0. If the first rolled back, the second proceeds on the original row. Locally (PostgreSQL 14), session 2 blocked 1.5 s, then returned zero rows.

Branch on the result: one row means you won, zero means you lost. Code that ignores the count still has the original bug.

Code box with UPDATE seats SET owner = A WHERE seat = A12 AND owner IS NULL, and results fan A 1 row with a tick, fan B 0 rows, labelled atomic update
CAS on a row: the first update changes 1 row, the second re-checks and changes 0.

Two caveats. The re-check covers the target row only: the command sees concurrent changes to the rows it updates but not to other rows, so a WHERE with a subquery on another table can pass on stale data. And SET stock = stock - 1 WHERE stock > 0 is safe (recomputed on the new version), while SET stock = 7, with 7 computed in the app from an earlier read, is a lost update.

MVCC in one section

PostgreSQL uses multiversion concurrency control: an UPDATE writes a new row version (tuple) and marks the old one ended. Each tuple header carries xmin, the transaction that created it, and xmax, the one that deleted, updated or locked it. A snapshot records which transactions count as committed; a tuple is visible if its creator is committed in the snapshot and its ender is not.

So readers never block writers and writers never block readers: a reader picks the version its snapshot sees. Two writers on one row do block, because the second must learn whether the first committed. Row locks live in the tuple header, not in shared memory, so there is no limit on rows locked, and individual row locks do not appear in pg_locks (a waiter shows as waiting on the holder's transaction id). Read Committed takes a new snapshot per statement; Repeatable Read and Serializable keep the first one for the whole transaction.

Isolation levels and the anomalies they permit

PostgreSQL's table differs from the SQL standard in two places: Read Uncommitted behaves as Read Committed (no dirty reads), and Repeatable Read also forbids phantoms. Lost update and write skew are not in the standard's table; the columns below follow the documented behaviour, each reproduced locally.

Level in PostgreSQLDirty readNon-repeatable readPhantomLost updateWrite skew
Read Uncommitted (runs as Read Committed)NoYesYesYes, app read-modify-writeYes
Read Committed (default)NoYesYesYes, app read-modify-writeYes
Repeatable Read (snapshot isolation)NoNoNoNo: 40001Yes
Serializable (SSI)NoNoNoNo: 40001No: 40001

A dirty read sees uncommitted data; a non-repeatable read gets different committed values for one row; a phantom gets a different set of rows for one search.

Read Committed. Each statement sees everything committed before it began. Single-statement CAS is safe through the re-evaluation; two statements in one transaction can see two different committed states.

Repeatable Read. One snapshot for the transaction. If it updates or locks a row that a concurrent transaction updated and committed after the snapshot, there is no re-evaluation: it fails with could not serialize access due to concurrent update. "First updater wins" turns naive check-then-act into an error instead of a double sale; locally, both sessions read free, the first committed, the second got the error.

Write skew survives it. Rule: at most 4 seats per fan; the fan holds 3. Two sessions each count 3 and each claim a different seat. No row was written by both, so first-updater-wins never fires and the fan ends with 5 seats. Snapshot isolation gives each transaction a consistent view, but nothing checks that the two writes together still obey the rule.

Serializable is Serializable Snapshot Isolation (SSI): snapshot isolation plus dependency tracking. Reads take SIRead predicate locks that block nobody; they record what was read (tuple, page or relation, by plan). When the dependencies form a "dangerous structure" (two consecutive read/write conflicts) that no serial order could produce, one transaction fails with could not serialize access due to read/write dependencies among transactions. The same test locally: the second session was cancelled and the fan kept 4. The contract: a transaction correct alone is correct in any mix, or it does not commit. Costs: retries, predicate-lock memory and false positives, more on sequential scans (relation-level locks), so index the queries.

A 40001 can arrive on any statement, including COMMIT. Retry the whole transaction from its first read, with backoff and a cap.

Row locks, SKIP LOCKED, NOWAIT and deadlocks

When the rule needs app logic, lock on read. SELECT ... FOR UPDATE behaves like an UPDATE when it meets a locked row: it waits, re-evaluates its condition on the new version and returns that version or nothing. This is pessimistic concurrency: lock, decide, write, commit.

There are four row lock strengths. FOR UPDATE conflicts with all. An ordinary UPDATE that changes no key column takes FOR NO KEY UPDATE, which still allows FOR KEY SHARE, the lock a foreign-key check takes, so inserting a payment that references a seat does not wait on an update of that seat's status.

  • Wait (default): block until the holder ends, with no limit unless you SET LOCAL lock_timeout, which fails with 55P03.
  • NOWAIT: fail at once with could not obtain lock on row (55P03), for "someone is checking out this seat" without making the user wait.
  • SKIP LOCKED: skip rows others have locked. With ORDER BY ... LIMIT n it hands out "any n free seats" or "the next n jobs" without queueing; locally, session 1 took A1 and A10 and session 2 got A11 and A12 at once. The view is deliberately inconsistent: right for queues and best-available, wrong for reports.

Deadlocks. Session 1 locks A1 and wants A2; session 2 locks A2 and wants A1. After waiting deadlock_timeout (default 1 s) PostgreSQL checks for a cycle and aborts one, unpredictably, with 40P01. Lock in a fixed order (WHERE seat = ANY($1) ORDER BY seat FOR UPDATE) and keep the retry loop.

The cost of pessimism: the lock lives as long as the transaction. Hold it across a 2 s payment call and each buyer behind you waits 2 s more.

Versions, constraints, holds and idempotency

Optimistic concurrency

When read and write are minutes apart (an edit form), read a version and write WHERE id = $1 AND version = $2, setting version = version + 1. Zero rows means someone wrote in between: re-read and retry or show a conflict. Cheap when conflicts are rare, a retry storm when common. For a hot seat, the state condition status = 'free' is better: losers fail once and have nothing to retry.

Unique constraints

A unique index binds every writer: the API, an admin script, a backfill, next year's refactor. With UNIQUE (event_id, seat) on tickets, two tickets for one seat are impossible. Concurrent inserts of one key serialise too: the second waits, then fails with 23505 (or proceeds if the first rolled back); ON CONFLICT DO NOTHING turns that into a row count. It can only enforce key uniqueness, so it is a net beneath the claim and the claim still does the work.

Holds, expiry and clocks

Checkout takes minutes, so the click claims a hold: status = 'held', holder, hold_expires_at = now() + interval '10 minutes'. Expiry is derived on read: the claim accepts "free, or held with hold_expires_at <= now()", and every display query uses the same predicate. A stalled cleanup job then harms nothing.

Countdown ring at 00:23 for A12 on hold, with two outcomes: paid in time leads to SOLD, timeout leads to FOR SALE, labelled reservation with a timeout
A hold is a state with an expiry; the expiry is a comparison in the query, not a job.

Compare against the database's clock, in the same statement. App servers' clocks differ and step under NTP; one host writing a timestamp and another judging it is a race. now() is fixed at transaction start, statement_timestamp() at statement start, clock_timestamp() moves even within a statement. Use now() in short claims, statement_timestamp() in a long transaction.

Idempotency on payment

Requests arrive at least once: a double tap, a retry after a lost response, a webhook delivered twice. Exactly-once effects are at-least-once delivery plus deduplication. One key per checkout, sent with every attempt, inserted as a primary key with ON CONFLICT DO NOTHING RETURNING: a row back means first attempt, none means a repeat, so return the stored result. Pass the same key to the provider (Stripe accepts an Idempotency-Key header) so the charge happens once. The confirm is another CAS, held by this fan and unexpired becomes sold; zero rows means the hold lapsed during payment, so refund instead of forcing the sale.

Why distributed locks are rarely needed

A Redis or ZooKeeper lock on "seat:A12" is a second system guarding a row whose database can already decide atomically, and it is a lease. A holder pauses (a stop-the-world garbage collection, a VM migration, a slow network), the lease expires, another process takes it, and the first wakes up still believing it holds the lock. Both write. Pauses are unbounded, so timeouts cannot exclude this. The remedy in the distributed-locking analysis under Further reading is a fencing token: a strictly increasing number per grant, carried by every write, with the storage rejecting tokens older than one it has seen. At that point the storage makes the decision with a conditional write, which you could have used directly. Use such locks for efficiency only.

Consistency models

Isolation levels describe one database; consistency models describe what clients observe of a replicated system (Jepsen's definitions):

Linearizability: single objects, real time. Each operation appears to take effect at one instant between its start and completion, so after a write completes every later read sees it. This is "sold is sold".

Serializability: transactions over many objects equal some serial order, with no real-time promise; your transaction may be ordered before one that finished earlier. Strict serializability adds the real-time order.

Eventual consistency: replicas converge once writes stop; meanwhile reads may be old and readers may disagree.

Two phones for seat A12: fan A's shows Yours with a tick, fan B's shows Taken. Pick another, labelled strong consistency
Ownership must look the same to every reader the moment it is decided.

So claims, confirms and ownership reads go to the primary. A streaming replica applies WAL after a delay, so checking there is check-then-act with a window of milliseconds to seconds. synchronous_commit = remote_apply makes the primary wait until a synchronous standby has applied a commit before acknowledging it, but the decision should still be a CAS on the primary. Display data (map colours, "about 120 left") may come from a replica or cache: if stale, the claim returns 0 rows and the user picks again. A feed is the opposite case, almost all eventual, as in designing a feed with fan-out on write or on read.

Two rule cards: a like count, almost right is fine; a seat or money, must be exact
Choose the model per data item: counts may be eventual, ownership and money may not.

Worked example

50,000 buyers, 5,000 seats, sale at 10:00; fan A and fan B click seat A12 together. How often does the naive claim collide, and what does each fix cost? Assumed numbers are round guesses, not measurements.

Buyers per seat (average)          50,000 / 5,000               = 10
Race window, check to write        20 ms (assumed)               = 0.02 s

Hot seat: 30 clicks in the first 3 s (assumed)
Click rate on that seat            30 / 3 s                     = 10 /s
Expected other clicks in a window  10 x 0.02                    = 0.2
P(at least one), Poisson           1 - e^(-0.2) = 1 - 0.819     = 0.18
100 hot front-row seats            100 x 30                     = 3,000 clicks
Clicks overlapping another         3,000 x 0.18                 = about 540

Conditional UPDATE, lock held about 1 ms (assumed)
Worst wait for the 30th click      30 x 1 ms                    = about 30 ms

FOR UPDATE held across a 2 s payment call (assumed)
Worst wait for the 30th click      29 x 2 s                     = 58 s

Abandoned checkouts: 30% (assumed)  5,000 x 0.30               = 1,500 seats
No expiry: unsold for good          1,500 / 5,000              = 30% of the hall
Expiry only by a 60 s cleanup job: extra time held, worst case = 60 s, mean 30 s
Expiry derived on read:            extra time held              = 0 s

A faster server shrinks the 18%, never to zero; the CAS removes the window.

Code

Tested on PostgreSQL 14; the behaviour is unchanged in current versions. Open two psql sessions on a scratch database. Run the reset before each demo.

CREATE TABLE seats (
  event_id        integer     NOT NULL,
  seat            text        NOT NULL,
  status          text        NOT NULL DEFAULT 'free'
                  CHECK (status IN ('free', 'held', 'sold')),
  holder          text,
  hold_expires_at timestamptz,
  version         integer     NOT NULL DEFAULT 0,
  PRIMARY KEY (event_id, seat)
);
INSERT INTO seats (event_id, seat)
SELECT 1, 'A' || n FROM generate_series(1, 20) AS n;

-- Safety net: one ticket per seat, whatever the code does.
CREATE TABLE tickets (
  event_id integer NOT NULL,
  seat     text    NOT NULL,
  fan      text    NOT NULL,
  UNIQUE (event_id, seat)
);

-- One row per checkout, keyed by the client's idempotency key.
CREATE TABLE payments (
  idempotency_key text        PRIMARY KEY,
  event_id        integer     NOT NULL,
  seat            text        NOT NULL,
  fan             text        NOT NULL,
  amount_cents    integer     NOT NULL CHECK (amount_cents > 0),
  status          text        NOT NULL DEFAULT 'started',
  created_at      timestamptz NOT NULL DEFAULT now(),
  FOREIGN KEY (event_id, seat) REFERENCES seats (event_id, seat)
);

-- Reset between demos.
UPDATE seats SET status = 'free', holder = NULL, hold_expires_at = NULL, version = 0;

The claim. Session 1: BEGIN; and the statement, left open. Session 2: the same with 'fan-b'. Observe: session 2 blocks; when session 1 commits, it returns no rows, UPDATE 0.

UPDATE seats
SET status = 'held',
    holder = 'fan-a',
    hold_expires_at = now() + interval '10 minutes',
    version = version + 1
WHERE event_id = 1 AND seat = 'A12'
  AND (status = 'free'
       OR (status = 'held' AND hold_expires_at <= now()))
RETURNING seat, holder, hold_expires_at;

Repeatable Read. Both sessions run the first two lines; session 1 runs the UPDATE and commits; then session 2 runs its UPDATE. Observe: could not serialize access due to concurrent update; A12 keeps fan-a.

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT status FROM seats WHERE event_id = 1 AND seat = 'A12';
UPDATE seats SET status = 'sold', holder = 'fan-a'   -- 'fan-b' in session 2
WHERE event_id = 1 AND seat = 'A12';
COMMIT;

Write skew. Give fan-a three seats, then both sessions run BEGIN and the count (3 each); session 1 claims A4 and commits; session 2 claims A5 and commits. Observe: at REPEATABLE READ the fan ends with 5; at SERIALIZABLE, as written, session 2 fails with read/write dependencies among transactions and the fan keeps 4.

UPDATE seats SET status = 'held', holder = 'fan-a',
       hold_expires_at = now() + interval '10 minutes'
WHERE event_id = 1 AND seat IN ('A1', 'A2', 'A3');

BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) AS held_by_fan
FROM seats
WHERE event_id = 1 AND holder = 'fan-a'
  AND (status = 'sold' OR hold_expires_at > now());
-- app: continue only if held_by_fan < 4
UPDATE seats
SET status = 'held', holder = 'fan-a',
    hold_expires_at = now() + interval '10 minutes'
WHERE event_id = 1 AND seat = 'A4' AND status = 'free';  -- 'A5' in session 2
COMMIT;

Best available and failing fast. Session 1 runs the first block and stays open; session 2 runs it and gets the next two seats at once, then commits and runs the last two statements. Observe: both fail with 55P03, since session 1 still holds A1.

BEGIN;
SELECT seat FROM seats
WHERE event_id = 1 AND status = 'free'
ORDER BY seat
LIMIT 2
FOR UPDATE SKIP LOCKED;
-- ... UPDATE the returned seats to held, then COMMIT

SELECT seat FROM seats WHERE event_id = 1 AND seat = 'A1' FOR UPDATE NOWAIT;
-- ERROR: could not obtain lock on row in relation "seats"

BEGIN;
SET LOCAL lock_timeout = '200ms';
UPDATE seats SET version = version + 1 WHERE event_id = 1 AND seat = 'A1';
-- ERROR: canceling statement due to lock timeout
ROLLBACK;

Optimistic version. The second statement carries the stale version and changes 0 rows.

SELECT status, version FROM seats WHERE event_id = 1 AND seat = 'A13';  -- version 0
UPDATE seats SET holder = 'fan-a', version = version + 1
WHERE event_id = 1 AND seat = 'A13' AND version = 0 RETURNING version;  -- 1 row
UPDATE seats SET holder = 'fan-b', version = version + 1
WHERE event_id = 1 AND seat = 'A13' AND version = 0 RETURNING version;  -- 0 rows

Pay and confirm. Run the claim for fan-a first. Observe: the INSERT returns the key once and nothing on a repeat; the confirm sells the seat and writes the ticket in one statement; a second ticket for A12 fails with a unique violation.

INSERT INTO payments (idempotency_key, event_id, seat, fan, amount_cents)
VALUES ('chk-7f3a9c', 1, 'A12', 'fan-a', 8000)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key;
-- no row back: a repeat; SELECT status FROM payments WHERE idempotency_key = ...

-- after the provider confirms the charge (outside any transaction):
BEGIN;
WITH sold AS (
  UPDATE seats
  SET status = 'sold', hold_expires_at = NULL, version = version + 1
  WHERE event_id = 1 AND seat = 'A12'
    AND status = 'held' AND holder = 'fan-a'
    AND hold_expires_at > now()
  RETURNING event_id, seat, holder
)
INSERT INTO tickets (event_id, seat, fan)
SELECT event_id, seat, holder FROM sold
RETURNING seat, fan;
-- 1 row: mark the payment confirmed and COMMIT.
-- 0 rows: the hold lapsed; ROLLBACK and refund.
UPDATE payments SET status = 'confirmed' WHERE idempotency_key = 'chk-7f3a9c';
COMMIT;

INSERT INTO tickets (event_id, seat, fan) VALUES (1, 'A12', 'fan-b');
-- ERROR: duplicate key value violates unique constraint "tickets_event_id_seat_key"

-- What the seat map shows: expiry derived on read.
SELECT seat,
       CASE
         WHEN status = 'sold' THEN 'sold'
         WHEN status = 'held' AND hold_expires_at > now() THEN 'held'
         ELSE 'free'
       END AS shown_as
FROM seats
WHERE event_id = 1
ORDER BY seat;

-- Housekeeping only; correctness never depends on it.
UPDATE seats
SET status = 'free', holder = NULL, hold_expires_at = NULL
WHERE status = 'held' AND hold_expires_at <= now();

Failure modes and edge cases

Ignored row counts. This is the form most often seen in production. Detect it with a scheduled invariant query (seats sold, tickets issued and payments confirmed per event must agree) and alert on any difference.

Hot rows. Every buyer of the front-row seat queues on one lock, so the queue, not CPU, sets latency. Watch pg_stat_activity for wait_event_type = 'Lock' and enable log_lock_waits (waits longer than deadlock_timeout). Keep the claim one short statement.

Retry storms. Under heavy contention retries can outnumber work; use jittered backoff with a cap.

Long transactions. An ORM session spanning a request, or a payment call inside a transaction, holds locks and blocks vacuum. Set idle_in_transaction_session_timeout and statement_timeout.

Poolers. In transaction pooling a session SET leaks to the next client; use SET LOCAL.

Paid after expiry. Charged at 10:09:58, confirmed at 10:10:01, 0 rows. Correct: refund or offer another seat, and count these.

Failover. With asynchronous replication a crash can lose acknowledged commits, so the promoted replica may show a sold seat as free. Whether that is acceptable is a non-functional requirement to settle early, as in writing functional and non-functional requirements and SLOs.

Trade-offs

OptionGood forCosts youUse when
Conditional UPDATE (CAS)Claiming one row: seat, coupon, stockRule must fit one WHERE on the target rowDefault for "only one may win"
SELECT ... FOR UPDATEApp logic over locked rowsLock held for the transaction; deadlocksShort transaction, fixed lock order
FOR UPDATE SKIP LOCKEDBest available, job queuesInconsistent viewAny one of many rows will do
Optimistic versionLong read-to-write gapsRetries under contentionEdit forms, rare conflicts
Repeatable ReadConsistent reads; same-row lost updates40001 retries; write skew remainsReports, single-row read-modify-write
Serializable (SSI)Rules across rowsRetries, false positives, lock memoryInvariants no row or constraint can hold
Unique constraintEvery code path23505 to handleAlways, beneath the main mechanism
Distributed lock (lease)Work outside one databaseExtra system; leases expire under pausesEfficiency only, or with fencing

Common mistakes

Mistake: believing BEGIN ... COMMIT around SELECT and UPDATE prevents the race. Instead: put the condition in the UPDATE, lock with FOR UPDATE, or raise the level with retries.
Mistake: raising the isolation level with no retry loop. Instead: retry the whole transaction on 40001 and 40P01, including failures at COMMIT.
Mistake: assuming Repeatable Read protects multi-row rules. Instead: per-fan limits are write skew; use Serializable, a parent-row lock or a constraint.
Mistake: calling a payment API while holding a row lock. Instead: hold in a short transaction, pay outside it, confirm with a second CAS.
Mistake: judging expiry by the app's clock or only by a cleanup job. Instead: compare with the database's now() in the claim and every read.
Mistake: adding a Redis lock "to be safe" and trusting its TTL. Instead: let the database decide; if a lock is unavoidable, check a fencing token in storage.

In an interview

Clarify: assigned seats or general admission, checkout length, per-buyer limits. Assumptions: one PostgreSQL primary for seat state, a waiting room, 10-minute holds. Structure: map (eventual), claim (CAS), hold, pay (idempotent), confirm (CAS), ticket (unique). Deep dive: the lost update, Read Committed re-evaluation, the row count, SKIP LOCKED. Trade-offs: pessimistic versus optimistic, Repeatable Read versus Serializable, no distributed lock. Wrap up: invariant monitoring, retries, replicas and failover.

A 60-second answer

Reading the seat and then writing it is check-then-act, so two transactions can both see it free. I close the window with one conditional UPDATE: held where free or the hold expired by the database's now(), with RETURNING, branching on the row count. At Read Committed a concurrent UPDATE waits on the row lock, re-evaluates the WHERE on the committed version and gets zero rows. Best available uses FOR UPDATE SKIP LOCKED. A four-per-buyer limit is write skew, so that path runs Serializable with retries on 40001. Payment carries an idempotency key; the confirm is a second CAS inserting into tickets with a unique (event, seat) constraint; a lapsed hold means refund. Claims and ownership reads go to the primary; the map can be eventual.

Practice

BeginnerAfter a conditional UPDATE, how does the app know it won?

The affected row count or the RETURNING rows: one row means it changed the seat, zero means someone else did.

IntermediateGive a race condition with no data race.

SELECT then UPDATE from two sessions: each statement is synchronised by the database, yet the interleaving sells a seat twice.

IntermediateAt Read Committed, session 2's UPDATE ... WHERE status = 'free' waits on session 1, which commits status = 'held'. What happens?

The WHERE is re-evaluated on the updated version, fails, and session 2 reports UPDATE 0. Had session 1 rolled back, session 2 would update the original row.

IntermediateWhy is SET stock = stock - 1 safe but SET stock = 7, computed in the app, not?

The expression is recomputed on the newest version after the wait; the literal overwrites a concurrent change.

AdvancedWhy does Repeatable Read stop a double sale but not a fifth seat under a four-seat limit?

First-updater-wins fires only on the same row. Each transaction writes a different seat: write skew, permitted by snapshot isolation and caught by SSI.

AdvancedTransactions lock seats in click order. What breaks?

Opposite orders deadlock; one is aborted with 40P01 after deadlock_timeout. Lock in canonical order and retry.

AdvancedWhy is a lease lock unsafe alone?

A paused holder outlives its lease while another acquires it. A fencing token checked by storage rejects the stale write.

AdvancedLinearizable or serializable: which does "sold is sold" need?

Linearizability of the ownership record. Serializability alone permits an order inconsistent with real time; claims and reads on one primary give the needed behaviour.

Cheat sheet

Term or formulaMeaning
Race condition / data raceTiming-dependent bug / unsynchronised memory access with a write
Check-then-act, read-modify-writeDecide or compute on a read, write later
Lost update / write skewOverwrite from a stale read / rule broken by writes to different rows
CAS, conditional UPDATEWrite only if the expected state holds; read the row count
Read Committed re-checkBlocked UPDATE re-evaluates WHERE on the new version
Repeatable ReadOne snapshot; same-row concurrent update raises 40001
Serializable (SSI)SIRead locks detect dangerous structures; 40001
MVCCNew tuple per update, xmin/xmax, snapshot visibility
FOR UPDATE / NOWAIT / SKIP LOCKEDLock and wait / fail (55P03) / skip locked rows
DeadlockLock cycle, checked after deadlock_timeout (1 s), 40P01
Optimistic concurrencyWHERE version = expected, SET version = version + 1
HoldExpired (hold_expires_at <= now()) counts as free on read
now() / statement_timestamp() / clock_timestamp()Transaction start / statement start / wall clock
Idempotency key, fencing tokenDedup repeats / reject stale lock holders
Linearizable / serializable / eventualReal-time single object / some serial order / converges later
P(collision) per click1 - e^(-rate x window)

Further reading

This is a simplified, general explanation of common patterns (a typical design), not a description of any one company's private systems. Numbers marked assumed are round examples for learning, not measurements.

Frequently asked questions

What is the difference between a race condition and a data race?

A data race is a memory-model term: two threads touch the same memory location without synchronisation and at least one writes. A race condition is any bug whose result depends on timing. SQL check-then-act has no data race but is still a race condition.

Why does UPDATE ... WHERE status = 'free' not sell a seat twice at Read Committed?

The second UPDATE blocks on the row lock. When the first commits, PostgreSQL re-evaluates the WHERE clause against the updated row version, finds status no longer free and skips the row, so it reports UPDATE 0.

Does Repeatable Read prevent lost updates in PostgreSQL?

Yes for updates of the same row: if a concurrent transaction committed an update to a row you then try to update, you get could not serialize access due to concurrent update (SQLSTATE 40001) and must retry. It does not prevent write skew across different rows.

When should I use SELECT ... FOR UPDATE SKIP LOCKED?

When any one of many rows will do, such as best available seat or a job queue. Each transaction locks rows nobody else has locked and skips the rest, so workers do not queue behind each other.

Do I need a distributed lock for seat booking?

Rarely. The seat row lives in one database that can decide atomically. A lease-based lock can expire while a paused process still believes it holds it, so the storage must check a fencing token anyway.

Which clock should decide whether a hold has expired?

The database's now(), in the same statement that claims or confirms. App servers' clocks drift and differ, and comparing a timestamp written by one machine with a clock on another reintroduces a race.

Sources

  1. PostgreSQL documentation: Transaction Isolationpostgresql.org
  2. PostgreSQL documentation: Explicit Lockingpostgresql.org
  3. PostgreSQL documentation: SELECT, the locking clausepostgresql.org
  4. PostgreSQL documentation: Introduction to MVCCpostgresql.org
  5. How to do distributed locking (Martin Kleppmann)martin.kleppmann.com
  6. Jepsen: Consistency Modelsjepsen.io

system designrace conditiondatabasepostgresqlconsistencylocking

System Design Simple logo
System Design Simple

Written by a software engineer and system architect with 10+ years of building production web, payment, ticketing and accounting systems in Japan and Nepal. About us