Prevent Double Booking with Atomic Claims and Holds

Relational Databases for Designers · 45 min

System Design · Module 3 · Lesson 4 of 4 · 45 min

Prevent Double Booking with Atomic Claims and Holds

Run a two-session booking race, interpret conditional-update results, and design short-lived checkout holds without holding transactions open.

Step through the booking race →
A seat is booked only when the authoritative atomic claim succeeds; an earlier free-seat read is not a reservation.

01 The idea

The problem this lesson solves

Show 1, seat F7 has booked_by = NULL. Both clients have an old screen showing it available.

Prerequisite: earlier lessons introduce the vocabulary and shared examples used here. Follow the module order.

Conditional updateA write whose predicate checks that the row is still eligible.
Row lockCoordination that makes conflicting row operations wait or fail according to database rules.
HoldPersisted temporary ownership with an identity, state and expiry.

02 Worked example

Follow one worked example

Prevent Double Booking with Atomic Claims and Holds worked diagram01A reads freeNo ownership yet02A updatesLocks and claims03B waitsCompeting write04A commitsB rechecks: conflictRead events in time order, from left to right.Prevent Double Booking with Atomic Claims and Holds compact diagram01A reads freeNo ownership yet02A updatesLocks and claims03B waitsCompeting write04A commitsB rechecks: conflict
A reads free: No ownership yet • A updates: Locks and claims • B waits: Competing write • A commits: B rechecks: conflict.

Trace the example in order. Follow the same identities, units and state transitions through the diagram and the events below.

1
Initial stateShow 1, seat F7 has booked_by = NULL. Both clients have an old screen showing it available.
2
A claims the rowA's conditional UPDATE returns F7 and holds the row lock until commit. A has not yet replied to the user.
3
B overlapsB issues the same conditional UPDATE with its own identity. It waits while A's transaction is open.
4
Commit and recheckA commits. B reevaluates booked_by IS NULL, finds it false and returns zero rows. The application reports that the seat was taken.

A seat is booked only when the authoritative atomic claim succeeds; an earlier free-seat read is not a reservation.

03 The method

Mechanisms decisions and a guided tutorial

Work through the explanation in order, then apply it in the tutorial. Each comparison names a cost as well as a benefit.

Choice or conceptPurposeBoundary or cost
Separate read + unconditional writeEasy to writeCan overwrite another booking
Conditional UPDATEAtomic single-row claimMust check returned or affected rows
SELECT FOR UPDATESerializes a multi-step row decisionLocks must be short-lived
Version predicateDetects conflicting changesApplication must handle conflict
SerializableProtects serializable transaction outcomesWhole transaction retries may be needed

Make claiming a seat one decision

UPDATE show_seats SET booked_by = 'A' WHERE show_id = 1 AND seat_no = 'F7' AND booked_by IS NULL RETURNING seat_no combines eligibility and change. At Read Committed, a concurrent update waits on the row; after the first transaction commits, PostgreSQL rechecks the predicate against the updated row. One contender gets one returned row, the other gets none. The application treats zero rows as a conflict, not a successful booking. A unique reservation row is another useful pattern when ownership is represented as an insert.

Locks and versions solve related problems

SELECT ... FOR UPDATE is useful when a decision needs several steps before updating the locked row. Keep the transaction short. Optimistic locking instead includes a version in the update predicate and treats a zero-row update as a conflict requiring reread or retry. Serializable can protect more complex invariants but needs correct retry handling. Choose the smallest mechanism that enforces the stated rule, and keep database constraints as a final guard.

Do not keep a transaction open during checkout

A person can take minutes to pay. Holding row locks during that interaction ties up connections and blocks other users. Model a time-limited hold with an owner, expiry and state. Converting the hold to a booking must verify ownership and expiry atomically. Multiple-seat transactions should acquire row locks in a consistent order to avoid the usual lock-order cycle. Other transactions or resources can still introduce deadlocks, so handle database aborts even with careful ordering.

Separate UI state from ownership

The seat map is a view of some earlier moment. Two people can both see F7 free without either owning it. The claim must check current eligibility and change ownership in one defended operation. At Read Committed, a second PostgreSQL updater waits for the first row writer and then reevaluates its predicate against the updated version.

The application must inspect RETURNING or the affected-row count. One returned row means this request obtained the claim under the predicate. Zero rows means it did not. A successful SQL call with zero eligible rows is a business conflict, not a successful booking.

Use holds for human-length workflows

Checkout can take minutes. Keeping a transaction and row lock open throughout that interval consumes connections and prevents useful work. Instead persist a hold with a unique hold_id, holder identity, expiry and state. Confirmation checks that the same hold still owns the seat and satisfies the expiry policy.

A late payment callback must not overwrite a newer holder. Include hold identity or a version in the confirmation predicate and make callbacks idempotent. If payment and seat ownership disagree, the business workflow needs reconciliation or refund behavior; a SQL transaction cannot atomically include an arbitrary remote payment.

Several seats need one all-or-nothing decision

For F7 and F8, acquire or inspect the complete requested set inside a short transaction. Lock rows in a canonical order, confirm both exist and remain available, then update both and commit. Roll back if any required seat fails.

Canonical ordering prevents the usual cycle caused by two transactions requesting the same pair in opposite orders. Continue to handle deadlock and serialization aborts because additional tables and code paths can still introduce conflicts. Keep a retryable database abort distinct from an unavailable-seat response.

Guided tutorial

1. Create disposable data

Use a local practice database and run the setup below once. Open two psql sessions connected to that same database. Do not run the lab against production data.

2. Hold the first claim

In session A run BEGIN ISOLATION LEVEL READ COMMITTED; then the UPDATE below with owner A. It returns F7. Leave the transaction open.

3. Compete from session B

In session B run the same UPDATE with owner B. The statement should wait. Return to A and COMMIT. B should finish with no returned row and UPDATE 0.

4. Verify and reverse

SELECT * FROM sd_lab_seats should show owner A. Reset booked_by to NULL after both transactions finish, repeat, and ROLLBACK A instead of committing. B can now claim the row. Rollback did not consume the seat.

CREATE TABLE sd_lab_seats (
  show_id integer, seat_no text, booked_by text,
  PRIMARY KEY (show_id, seat_no)
);
INSERT INTO sd_lab_seats VALUES (1, 'F7', NULL);
-- In session A, after BEGIN ISOLATION LEVEL READ COMMITTED:
UPDATE sd_lab_seats SET booked_by = 'A'
WHERE show_id = 1 AND seat_no = 'F7'
  AND booked_by IS NULL RETURNING seat_no;

05 Cheat sheet

Review the decisions

Use this table to explain the mechanism and its limit without memorizing a product name.

Choice or conceptRememberCheck
Separate read + unconditional writeEasy to writeCan overwrite another booking
Conditional UPDATEAtomic single-row claimMust check returned or affected rows
SELECT FOR UPDATESerializes a multi-step row decisionLocks must be short-lived
Version predicateDetects conflicting changesApplication must handle conflict
SerializableProtects serializable transaction outcomesWhole transaction retries may be needed
Conditional updateA write whose predicate checks that the row is still eligible.
Row lockCoordination that makes conflicting row operations wait or fail according to database rules.
HoldPersisted temporary ownership with an identity, state and expiry.

06 Where & why

Check the primary references

Use official references for protocol and implementation details. The questions here are teaching prompts.

Primary reference
PostgreSQL 16 transaction isolation

Read PostgreSQL 16 transaction isolation

Primary reference
PostgreSQL explicit locking

Read PostgreSQL explicit locking

Primary reference
Multicolumn index behavior

Read Multicolumn index behavior

State the implementation version and the workload assumptions when defending your own design.

07 Interview questions

Explain the reasoning out loud

Answer before opening the explanation. The questions progress from vocabulary to decisions and failure behavior.

What does Conditional update mean?
A write whose predicate checks that the row is still eligible.
What does Row lock mean?
Coordination that makes conflicting row operations wait or fail according to database rules.
What does Hold mean?
Persisted temporary ownership with an identity, state and expiry.
Why can both customers see a free seat?
Their reads are observations rather than ownership claims. Only the later authoritative atomic transition decides who obtains it.
What does a blocked competing UPDATE do after the winner commits?
At the stated PostgreSQL Read Committed behavior it reevaluates the eligibility predicate against the newer row, so an already-booked seat returns no eligible row.
Why include hold identity in a callback?
The seat may have been released and held again. A stale callback must not confirm a newer holder’s claim.
Why sort seat ids before locking?
Consistent ordering removes the common opposing-lock-order cycle. It does not eliminate every possible database abort.
What if the first claimant rolls back?
The waiting contender can proceed if the row is still eligible. The failed transaction did not durably consume the seat.

08 Practice problems

Practice a new case

Solve the new cases before revealing hints. Complete worked solutions follow the set.

Seat identity

Easy
Explain why the booking predicate includes both show_id and seat_no.
Follow-up
State the assumption or edge case that makes the answer correct.
Show the hint
F7 can be sold for several shows.

Read the result

Easy
State the API outcome when a conditional reservation UPDATE returns no rows.
Follow-up
The database call itself did not fail.
Show the hint
Distinguish statement execution from business success.

Commit or rollback

Medium
Explain the waiting contender’s result when the first claimant rolls back instead of committing.
Follow-up
State the assumption or edge case that makes the answer correct.
Show the hint
The initial claim was not made durable.

Repair a deadlock

Medium
Explain how transactions locking seats in orders F1,F2 and F2,F1 can deadlock, and repair their order.
Follow-up
Each transaction needs both seats.
Show the hint
Draw who waits for whom.

Model a hold

Medium
List the fields and atomic eligibility checks needed to confirm a seat held for a user until a deadline.
Follow-up
A delayed payment callback can arrive after expiry.
Show the hint
The callback must not overwrite a newer owner's hold.

Two-seat atomicity

Hard
Design an operation that books F3 and F4 together or neither, including conflict and retry behavior.
Follow-up
One seat may already be booked.
Show the hint
Check the complete set inside a short transaction.

Worked solutions

1. Seat identity

The claim concerns one seat occurrence in one show. A seat label alone identifies too broad a set.

2. Read the result

Return an application-level conflict or unavailable result, such as a documented 409 response. The row was absent or no longer eligible; no reservation was obtained.

3. Commit or rollback

The waiting writer can proceed if the predicate remains eligible. Under the tutorial’s setup it claims F7 rather than returning zero rows.

4. Repair a deadlock

A holds F1 and waits for F2; B holds F2 and waits for F1. Both should lock in the same canonical order, such as F1 then F2. Still handle deadlock aborts because other access paths may lock additional resources.

5. Model a hold

Track holder_id, hold_id or version, expires_at and state. Confirm only when the same hold still owns the seat, the expected state is held and the expiry rule permits confirmation. Otherwise reconcile or refund the payment according to the business workflow.

6. Two-seat atomicity

Begin a transaction, lock the two rows in a consistent order, verify that both exist and are free, update both, and commit. If either is unavailable, roll back. Retry the entire transaction for retryable database aborts; do not retry a genuine unavailable-seat outcome as if it were transient.