One Seat Experiment
#database_locking#concurrency#postgres
One Seat Experiment
This article is part of a series of "experiments" that I run against a lab project, Event Forge — a fake ticket-selling platform I use to study and test concepts.
The main idea
Ticketing platforms get uneven traffic — it swings with the demand of specific events. What I want to check is concurrent behaviour on events where tickets are scarce and demand is high.
The same seat, requested N or more times.
A basic understanding of the data model

Event Forge has other tables besides the ones that show up here: organizers, venues, seat maps, price tiers. None of that matters for this particular experiment. Only three tables interest me for this study — and the fight to write to one of them in particular.
The allocations table, and the disputed row
allocations is the table I care about. It is where I can state which seats
are available at the moment a new event is published — the table takes a kind
of snapshot when an event changes state (draft -> published, for
example).
An important distinction about the idea of selling tickets for events: General Admission (GA) vs. seated.
General admission lets us sell a ticket without assigning a specific seat. For an event in a stadium with a GA capacity of 500 people, it does not matter where you sit. A seated event is different — there the specific seat (the chair itself, I might say) is what you are buying. That difference is not only about the ticket. It is what the table looks like:
- Seated — one row per seat,
capacity = 1. A section with 500 seats becomes 500 rows. Two buyers only meet when they ask for the same seat. - GA — one single row,
capacity = 500, working as a counter. Every buyer writes to the same row, so all of them queue on it.
So one table gives me two very different fights: many cold rows, or one hot row. The experiment needs both, and that is why my test data has both.
The other two tables
holds is the claim itself — who is holding, for which event, and until when
(expires_at, 15 minutes). hold_lines says what the claim is made of: one
line per allocation, with a quantity. One for a seat, N for a slice of a GA
counter.
Worth saying out loud: expiry is written but nothing enforces it yet. An expired hold keeps its units off sale. That is a different experiment.
Placing a hold
There is one endpoint, POST /events/{eventId}/holds, and it does four things
in this order:
- Check that the event is on sale. Nothing else accepts holds.
- Open a transaction and lock the allocation rows the request named.
- Judge the locked rows: are there enough units left?
- If yes, move the units and write the hold and its lines.
Everything interesting happens because step 2 comes before step 3.
The question
N attendees request the same seat, at the same instant. Does exactly one get it?
The important part is that "yes" has to be provable. A browser cannot show me a race — I would have to click faster than the network. A test can: fire many requests at the same seat at once and count the winners.
So the test fires 16 simultaneous claims for one seat, and repeats that 50 times, each round against a different seat. For every round it asserts:
- exactly 1 response is
201; - the other 15 are
409with the codeALLOCATION_UNAVAILABLE; - the seat row ends with
held = 1; - exactly one
hold_linepoints at that seat.
There is a second race for the GA counter, with capacity = 5: 16 claims, 5
winners, 11 losers, and the row ends at exactly 5.
How exactly one wins
The lock is one line of SQL:
SELECT "id", "event_id", "capacity", "held", "reserved"
FROM "allocations"
WHERE "id" = ANY($1::uuid[])
ORDER BY "id"
FOR UPDATEFOR UPDATE takes the row. A second transaction naming the same seat stops
right there and waits.
The winner is not the one who writes first — it is the one who arrives first. The loser never writes at all:
A, the winner |
B, the loser |
|
|---|---|---|
t0 |
locks the row, reads held = 0 |
— |
t1 |
— | tries to lock, blocks |
t2 |
UPDATE … SET held = 1 |
still waiting |
t3 |
COMMIT, lock released |
wakes up |
t4 |
— | re-reads the row, sees held = 1, gives up |
t4 is the whole mechanism. B does not decide from what it read when it
arrived; it decides from what A committed. It then returns
insufficient_units, which the service throws, which rolls its transaction
back — leaving no trace.
ORDER BY "id" is there for a reason too. If one request locks seat A then
seat B, and another locks B then A, each is holding what the other needs and
Postgres kills one with a deadlock error. From outside that looks exactly like
losing a seat. Sorting the ids means every request in the system locks in the
same order, so it cannot happen.
The safety net
Under all of it there is a CHECK constraint on the table:
CONSTRAINT "allocations_no_oversell_check" CHECK ("held" + "reserved" <= "capacity")This is the actual authority, not the lock. If everything I wrote above is wrong, Postgres still refuses the write and do not let me oversell
I checked that on purpose. Removing FOR UPDATE makes the test fail — and it
fails with losers failing for the wrong reason, not with two winners.
I have to pay a price
Losers wait, and they wait exactly as long as the winner's transaction lasts. So whatever I put inside that transaction is paid for by everyone else in the queue, and each of them is holding a database connection while waiting. That is why the response is read back after the commit, not inside it.
For the detailed reasoning behind each decision above, see takeaways.