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

Two buyers see F7 as available and press Book. Both screens were truthful when rendered, but neither screen reserved the seat. Correctness depends on one authoritative transition that turns “free” into “owned,” and on how a competing transaction observes that transition. We will build the claim, run the race and then extend the reasoning to temporary holds and payment.

You should recognize API resources and failure responses. SQL examples introduce their own tables and constraints.

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

Let two buyers compete for seat F7

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
B waits for A’s row lock. After A commits, B rechecks the condition and returns no claimed seat.
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.

03 The method

Understand the mechanism

Why reading availability is not ownership

The unsafe sequence is read seat, check free in application memory, then write the buyer. A and B can both complete the read before either writes. If both later perform unconditional updates, the second can overwrite the first while both callers receive success.

Putting those statements inside transactions does not automatically establish the needed invariant at every isolation level. The application has separated eligibility from the deciding write. The earlier read is information about a past observation, not a reservation.

Define the invariant precisely: for one show-seat identity, at most one active owner may hold the relevant booking state. Historical canceled or expired records can exist separately; uniqueness must apply to the active ownership representation, not accidentally prohibit all history.

The database needs a conflict point that competing claims cannot both pass. A conditional update of one authoritative row is one approach. Inserting one reservation under an appropriate unique constraint is another. Both move the decision out of two independent app processes.

Combine eligibility and change in one statement

Use UPDATE sd_lab_seats SET booked_by='A' WHERE show_id=1 AND seat_no='F7' AND booked_by IS NULL RETURNING seat_no. The predicate identifies both the seat and the condition required to claim it. The returned row reports that this statement changed the eligible row.

If the result contains F7, the transaction has made the claim but still needs to commit. If no row is returned, the condition did not hold for a matching row. The application must not treat a successfully executed SQL statement with zero changed rows as a successful booking.

Distinguish zero rows from a query error. Zero rows may mean already claimed or missing under this simple query. A connection timeout means the result is unknown to the caller and may require recovery using an operation identity. Do not collapse these outcomes into one boolean.

Keep database constraints as additional protection. The composite primary key ensures that the row really is the unique representation of that show seat. Without it, duplicate rows could allow one statement to “claim” several inconsistent copies.

Walk the blocking and recheck behavior

At PostgreSQL Read Committed, let A update F7 and keep its transaction open. A holds the relevant row lock. B issues the same conditional update with booked_by='B'. B cannot independently overwrite the row while A’s update is unresolved, so it waits.

If A commits, B proceeds against the updated row and rechecks booked_by IS NULL. The condition is now false, so B returns zero rows. A is the winner. The result is enforced at the deciding database operation, not by trusting either buyer’s earlier seat-map screen.

If A rolls back, its claim does not remain committed. B can then find the row eligible and claim F7. This is why the tutorial tests both commit and rollback instead of assuming “the first request that arrived always wins.”

The application should return success only after its transaction commits under the chosen contract. If it tells A “confirmed” before commit and the transaction later aborts, it has promised a booking that does not exist.

Compare other concurrency mechanisms

SELECT FOR UPDATE can lock the seat before a short sequence of checks and updates. This is useful when the decision requires several fields or related calculations. The code must still check eligibility while holding the relevant lock and keep the transaction short.

Optimistic concurrency adds a version predicate. A client that saw version 4 attempts an update where version=4 and advances it to 5. A zero-row result reports that its assumption no longer holds. This can avoid waiting in some application patterns but requires a conflict experience or retry policy.

A unique reservation insert can represent ownership directly. Two inserts for the same active seat identity compete on the unique key. Choose how cancellation and expiry interact with that representation; an unconstrained collection of status rows does not automatically enforce active uniqueness.

Serializable transactions can protect broader rules but may abort and require complete retries. Do not describe them as a reason to ignore unique constraints or retry handling. Compare mechanisms by the invariant they enforce, the amount of locked or retried work and the resulting user behavior.

Model a hold instead of locking during checkout

A customer may take minutes to enter payment details. Holding a database transaction and row lock for that entire period ties up connections and blocks others. Instead, store a durable hold with an owner, expiry and state, then end the transaction.

A later confirmation checks that the hold still belongs to the caller, remains valid and is in the expected state, and transitions it atomically. An expired hold can be made claimable according to a database-time-based rule or another explicitly defined clock model. A background cleanup job helps maintenance but should not be the only thing preventing use of an expired hold.

Hold expiry and payment introduce a real race. Payment can succeed after the hold becomes invalid. A local database transaction cannot make the external provider’s result atomic with seat ownership. Define an idempotent payment workflow, operation identities and reconciliation or refund behavior for the product’s chosen policy.

Do not hide this behind “use a distributed lock.” The system needs a state machine describing held, confirmed, expired and canceled transitions, plus recovery when external steps complete late. Locks alone do not define those business outcomes.

Extend to multiple seats and recovery

If one booking needs F7 and F8 together, decide whether partial success is allowed. For all-or-nothing ownership in one database, claim both within one transaction and roll back if either cannot be obtained. Acquire rows in a stable order to reduce deadlock cycles between overlapping requests.

Even with a stable order, handle deadlocks and transaction aborts. Other code paths and resources can still introduce cycles. Retry the complete transaction within a bounded deadline, using the same logical operation identity where required.

After a response loss, recover the booking by operation id or another documented identity. A client should not initiate a new independent purchase merely because its first confirmation response was lost. Store enough durable outcome information to answer the recovery query.

Finally, monitor more than successful bookings. Track claim conflicts, hold expirations, lock-wait duration, payment reconciliation backlog and duplicate-operation attempts. These signals distinguish healthy competition for popular seats from infrastructure failures or a broken workflow.

Guided tutorial

  1. Create the one-seat practice state

    Run only the setup portion below once in a disposable PostgreSQL database. Verify F7 has booked_by=NULL. Open two database sessions. In A, begin a Read Committed transaction and execute the conditional update for buyer A without committing yet.

  2. Make B compete

    In B, execute the same update with buyer B. It should wait while A remains open. Commit A and observe that B returns no seat. Query the stored owner from a fresh statement and verify A. Do not interpret B’s SQL completion as a booking confirmation.

  3. Test rollback as a different outcome

    Reset booked_by to NULL after both sessions have finished. Repeat A’s claim and B’s waiting attempt, but roll back A this time. B should now return F7 and become the stored owner. This demonstrates that committed state, not request arrival alone, decides the result.

  4. Design the checkout extension

    Add hold_owner, hold_expires_at and state to a paper schema. Write the predicates for acquiring and confirming a hold. Then place payment success after hold expiry on a timeline and specify the recovery action. Your answer must explain the external side effect, not leave a transaction open while the user pays.

PostgreSQL, two sessions

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;

Check your result

A’s conditional update returns F7. B waits while A holds the row lock; after A commits, B returns zero rows. If A rolls back instead, B can claim the seat.

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

06 Where & why

Check the primary references

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

Primary reference
PostgreSQL transaction isolation

Read PostgreSQL transaction isolation

Primary reference
PostgreSQL explicit locking

Read PostgreSQL explicit locking

Primary reference
PostgreSQL table constraints

Read PostgreSQL table constraints

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.
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.
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.