← Blog/Databases/Oct 10, 2026 · New

Two People Bought the Last Ticket. Both Got a Confirmation Email.

Omenabyte Intelligence·Oct 10, 2026·9 min read
Two wireframe figures on a cyan grid floor reaching for a single glowing amber ticket suspended between them

One seat left. Row F, seat 12. Maya in Chicago and Leo in Denver hit Buy inside the same millisecond, and both of them get a confirmation email.

Your code did nothing wrong. That's the part that should worry you.

The bug is a gap, not an error

Here's the flow nearly every application writes at some point:

seat = SELECT * FROM seats WHERE id = 'F12';
if (seat.status == 'available') {
    UPDATE seats SET status = 'sold' WHERE id = 'F12';
    send_confirmation_email();
}

Read, check, write. Three steps, and nothing in that snippet is wrong on its own.

Two lanes, Maya and Leo, each running read, check and write on the same row. An amber band between Maya's check and write contains Leo's read, showing both requests read the row as available before either write lands.
Both lanes do the same three correct steps. The amber band is the window where Leo's read lands between Maya's check and Maya's write. Diagram: Omenabyte Intelligence.

Now run two of them at once. Maya's request reads the row: available. Before her write lands, Leo's request reads the same row. Maya hasn't committed yet, so from where Leo is standing the seat is still available. Both checks pass. Both writes go through. Two emails, one chair.

This is a race condition, and the useful thing to understand is that it isn't a crash. It's the ordinary result of two requests arriving at the same time. Nothing threw. Nothing logged a warning. The database accepted both writes because both writes were valid requests. The outcome depended entirely on which header arrived first, and nobody wrote that rule down.

The name for the shape is check-then-act: you made a decision based on a value, then acted on a world that had already moved. Every version of this bug, in every language, is that same gap between the reading and the writing.

Why the database didn't stop you

It's tempting to assume the database should have caught this. It had all the information.

Two things make the double sale possible, and both are deliberate.

First, PostgreSQL's default isolation level is Read Committed. A plain SELECT sees a snapshot of the database as of the moment the query began. While Maya's transaction is open and uncommitted, her write is invisible to everyone else, which is exactly the point of running transactions. Isolation is supposed to hide in-progress work. So Leo legitimately sees available.

Second, and this matters more, a plain SELECT doesn't lock anything meaningful. Read Committed will happily let a hundred transactions read the same row simultaneously. Reads don't conflict with reads.

So the database behaved correctly. It was asked to record a fact that was true when it was written, and it did.

A data centre aisle with two rows of white server cabinets facing each other
The engine is doing what it was told. Nothing in the transaction was malformed; the problem is the shape of the operation, not the hardware running it. Photo: PiDatacenters / CC BY-SA 4.0

Fix one: make the second request wait

The direct approach is a row lock.

BEGIN;

SELECT * FROM seats
WHERE id = 'F12'
FOR UPDATE;

-- returns 'available', and the row is now locked for this transaction
UPDATE seats SET status = 'sold' WHERE id = 'F12';
COMMIT;

FOR UPDATE locks the retrieved rows as though for update. While Maya holds that lock, Leo's SELECT ... FOR UPDATE on the same row blocks. It doesn't fail, it doesn't return a stale value, it waits. When Maya commits, the lock releases and Leo's query proceeds and returns the updated row, which now says sold. Leo gets an error instead of a ticket, and the order of the queue was never in doubt.

This is pessimistic locking: assume contention, coordinate before anyone moves.

Four things decide whether it actually works.

The lock only exists inside a transaction

This is the mistake that makes people conclude locking doesn't work. In autocommit mode, a statement ends the moment it finishes, so the lock is released before your UPDATE ever runs. The lock has to span the read, the decision, and the write. BEGIN is not optional.

Lock the row you mean, not the table

WHERE status = 'pending' FOR UPDATE with no index on status scans the table and locks every matching row on the way past. Lock on a primary key or another indexed column and you get a narrow lock instead of a queue for the whole table.

An open frame network rack with cable management brackets and a raised floor with perforated vent tiles
A lock is a reservation on one row. Aim it at a column without an index and you have reserved far more than you meant to. Photo: Carl Lender / CC BY 2.0

Decide how long to wait

An unbounded wait turns a lock into an outage. FOR UPDATE NOWAIT fails immediately if the row is already locked (55P03, lock_not_available), and lock_timeout caps the wait at a duration you chose. Either way you can turn the failure into a retry, a "just taken" message, or a queued job.

Locks queue, and queues have a cost

This is the source video's honest caveat, and it's worth repeating: if thousands of fans are fighting over the same section, requests pile up behind the lock. Each one is short. The total wait grows with the length of the line.

A large festival crowd standing in front of a lit stage at dusk
Contention on one row queues everyone. Contention spread across ten thousand different rows barely conflicts at all. Photo: Wvdp / CC0

Fix two: delete the gap

The better fix changes the shape of the operation so there's nothing to interleave.

UPDATE seats
SET status = 'sold'
WHERE id = 'F12'
  AND status = 'available';

One statement. The check and the write are the same event, evaluated against the same row, in the same instant. Maya's update matches the row and changes it. Leo's update arrives, finds no row that is still available, and changes zero rows.

Maya's update changes one row. Leo's changes zero. That difference is the entire result of the race, and the database hands it to you for free.

Now the mechanism, because this is where most explanations stop and it's worth knowing why the UPDATE is safe rather than lucky. This is straight from the PostgreSQL manual on Read Committed:

The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition.

The part worth slowing down on: it isn't only that the two operations happen atomically, it's that if your UPDATE finds a row another transaction has already modified, it waits for that transaction, then re-checks your WHERE clause against the new version of the row. Leo's AND status = 'available' is re-evaluated against the row Maya just committed, fails, and the row is skipped.

So the guarantee isn't luck or a narrow timing window. It's a documented re-evaluation the engine performs for you.

The consequence is that the operation can succeed silently with nothing to do, and that's a trap:

UPDATE seats
SET status = 'sold'
WHERE id = 'F12' AND status = 'available'
RETURNING *;

If the result set comes back empty, the seat went to someone else. If you don't check, your code sails on believing it booked a seat it never touched, and you've rebuilt the original bug with extra steps. Zero rows affected is your conflict signal, and it only helps if you read it.

Fix three: put the guarantee in the schema

Application code has bugs. Your concurrency logic will be tested by people who don't know it's there, in code you haven't written yet.

CREATE TABLE tickets (
    id         bigserial PRIMARY KEY,
    event_id   bigint NOT NULL REFERENCES events (id),
    seat_id    text   NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    CONSTRAINT one_ticket_per_seat UNIQUE (event_id, seat_id)
);

With that constraint in place, two confirmed tickets for the same seat cannot exist in the table. Not "should not," not "unlikely to." The engine refuses the second insert and raises 23505, unique_violation, regardless of what your application layer believes.

Be clear about what this does and doesn't do. It does not make your checkout flow correct. It makes the final invariant unbreakable, which is a different and more valuable thing: it's the property that survives every future refactor, every new endpoint, every script somebody runs by hand at midnight.

It also moves a decision into your error handling. The unique violation is a normal outcome under contention, not an exceptional one. Catch 23505, translate it into "that seat just went," and move on. If you let it bubble up, your loser gets a 500 for a situation the design anticipated.

What each mechanism actually buys you

These are not three ways to do the same thing. They handle the conflict at different moments and give the loser a different experience:

Three panels: SELECT FOR UPDATE, conditional UPDATE, and a UNIQUE constraint, each showing its SQL, what the losing request experiences, and what class of problem it guards.
The same seat, three mechanisms. The conditional UPDATE is the recommended default; the constraint is what stays true after every other layer has been refactored. Diagram: Omenabyte Intelligence.

A booking system uses all three. The conditional update takes the seat. The constraint backstops it. FOR UPDATE earns its place when the decision needs several reads or writes against state that must not move underneath you, like validating a balance, writing a ledger entry, and updating an account.

The parts nobody puts in the cheat sheet

Deadlocks are real and have a code

If two transactions lock rows in opposite orders, one gets aborted with 40P01, deadlock_detected. The fix is ordering: always acquire locks on multiple objects in the same sequence, and take the most restrictive lock you'll need first. PostgreSQL detects deadlocks and breaks them, but it's choosing a victim, not resolving your design.

Serialization failures need a retry loop

Under Serializable, the engine can abort a transaction with 40001, serialization_failure, even when there's no obvious conflict. That's the contract: you retry the whole transaction from a fresh read, with bounded attempts and backoff. Retrying from where you left off defeats the purpose.

Isolation level changes what FOR UPDATE does on conflict

Under Read Committed, a blocked FOR UPDATE waits and then returns the updated row, as above. Under Repeatable Read or Serializable, it throws instead, because the row changed since your transaction's snapshot. Both are defensible. You just have to know which one you're getting.

Retries duplicate side effects

If a transaction can charge a card, send an email, or publish a message, a retry can do it twice. Keep external I/O outside the locked transaction wherever you can, and make the operations that must cross the boundary idempotent with a key the receiver understands.

Sometimes you want the queue to be skippable

SELECT ... FOR UPDATE SKIP LOCKED doesn't wait for a locked row, it moves on to the next one. For a work queue where workers are draining a table of jobs, that's the right behaviour, and it's the pattern the PostgreSQL manual names for exactly this case. It comes with a cost the manual also states: skipping locked rows gives an inconsistent view of the data, which is why it isn't suited to general purpose work. It's the wrong behaviour for a seat, where you want that specific row.

Two rules that outlive the example

Keep your checks inside the write, and keep the final guarantee in the schema.

The first rule is about where the decision happens: if a condition decides whether a write is allowed, that condition belongs in the WHERE clause, not in an if above it. The second is about who can be trusted: your application is code you're still writing, but a constraint is a property the engine will enforce against every caller forever, including the ones you haven't imagined.

Together they close the gap from both ends. The write stops being interruptible, and the outcome stops depending on anything the write got right.

Which has cost you more time, the bug, or the incident review after it?

Concurrency

Find out which of your writes can interleave.

We review schema invariants, transaction boundaries, and the places your code decides and writes as two separate steps, before they turn into duplicate rows in production.

Source & credits

The seat-booking framing, the Maya and Leo example, and the three-mechanism structure are from a Knowmore video on database concurrency, which states its lock and constraint guarantees accurately. The mechanism-level detail added here is from the PostgreSQL 18 manual: the WHERE re-evaluation under Read Committed, lock ordering and deadlock codes, NOWAIT and SKIP LOCKED, and the isolation-level differences. Photography from Wikimedia Commons under the licenses noted in each caption; both diagrams were drawn for this article.

Originally published on omenabyte.com → https://omenabyte.com/blog/the-gap-between-checking-and-writing