Implementing a high-concurrency system with Postgres
Knowing your database's primitives isn't optional trivia — your app's state lives inside them. If you don't understand what a database actually guarantees, you can't know what your code is silently relying on.
The problem we are solving
We want to design and develop the REST API service that will manage the event seat reservations of our new application.
The service is required to expose the following endpoints:
- Create an event. An event consists of several seats. The total number of seats is required to create the event and it could be anything between 10 and 1,000 (included).
- Hold a particular seat. Users can "Hold" a seat for a limited amount of time. This is particularly useful when other parts of the system are, for example, completing the confirmation flow and payment. In order to Hold a seat, your system will require the user identifier. A user can hold a seat for a configured maximum time of seconds, after which the seat will become available to other users. You can default this to 60 seconds.
- Reserve a particular seat. A user can complete the reservation of a seat, only if the user is "Holding" the relevant seat. After the reservation, this seat becomes permanently assigned to the user.
- List available seats for a given event. The list of available seats should only include the seats that are not "On Hold" and not yet fully Reserved.
Additional Points
- Limit the number of seats a given user can hold in one event.
- Add an endpoint to "refresh" a Hold on a seat.
I wrote an article about solving this same problem with Redis — Redis for a High-Concurrency Reservation System. Here we'll do it in Postgres — and lean on guarantees Redis simply can't give you.
Setting expectations: rejection is fine, double-booking is not
Under real concurrent load, some booking attempts will fail — and that's fine. A UI that says "Sorry, that seat was just taken — pick another one" is a perfectly acceptable outcome.
What's not acceptable is two different people both believing they hold or reserved the same seat, only discovering the conflict when they show up at the venue. That's the failure this lesson is about preventing — overbooking is a well-documented, recurring problem:
- Ticketmaster Sells Some Fans Duplicate Sugar Bowl Tickets — Ticketmaster itself confirmed some buyers "received two tickets to the same seat."
- Fan Had Ticket Revoked After Ticketmaster "Double Sold" His Floor Seat — the seat was double-sold, and the buyer with a valid ticket still lost it.
- On The Secondary Market, It's Buyer Beware When Tickets Are Sold Twice — the same seat sold to multiple buyers, and only one of them got in.
- FlySafair Denies Wrongdoing After Overbooking Scandal Referral — a 2026 case where a regulator says overbooking was systematic, not accidental.
Modeling the problem
Four tables. "user" is a catalog. event stores the total seat_number. seat belongs to one event (so locking a seat later only ever touches that event). reservation is the heart of it: one row per seat, with a status of H (holding) or R (reserved).
A transaction-scoped advisory lock will coordinate requests for each user/event pair, even when that user has no reservations yet. A partial index on reservation holds makes the limit lookup efficient.
The single most important line is PRIMARY KEY (event_id, seat_id). It means a seat can have at most one reservation row — so two people can never both hold or reserve the same seat. The double-booking invariant is enforced by the schema itself, not by application code.
CREATE TABLE "user" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL
);
CREATE TABLE event (
id UUID PRIMARY KEY DEFAULT uuidv7(),
name TEXT NOT NULL,
seat_number INTEGER NOT NULL
);
CREATE TABLE seat (
id SERIAL PRIMARY KEY,
event_id UUID NOT NULL REFERENCES event (id),
label TEXT NOT NULL
);
CREATE TABLE reservation (
event_id UUID NOT NULL REFERENCES event (id),
seat_id INTEGER NOT NULL REFERENCES seat (id),
user_id UUID NOT NULL REFERENCES "user" (id),
status CHAR(1) NOT NULL CHECK (status IN ('H', 'R')),
holding_date TIMESTAMPTZ,
reservation_date TIMESTAMPTZ,
PRIMARY KEY (event_id, seat_id)
);
CREATE INDEX reservation_live_hold_lookup
ON reservation (event_id, user_id, holding_date)
WHERE status = 'H';A hold lasts a limited time — we'll use 30 seconds here. Crucially, expiry is a rule, not a background job: we never delete a hold on a timer. A hold simply counts only while its holding_date is newer than statement_timestamp() - interval '30 seconds'. Once it's older than that, the seat is free again — the row can just sit there until someone takes it over.
These examples evaluate expiry at the start of the operation's SQL statement.now() is fixed at the start of the transaction, so it can be stale after waiting for a lock. Send the lock command and the subsequent write as separate commands on the same connection, and keep transactions short: a hold can expire before a slow transaction commits.
INSERT INTO "user" (id, name)
VALUES
('11111111-1111-1111-1111-111111111111', 'Ada'),
('22222222-2222-2222-2222-222222222222', 'Grace'),
('44444444-4444-4444-4444-444444444444', 'Bob');
INSERT INTO event (id, name, seat_number)
VALUES ('33333333-3333-3333-3333-333333333333', 'Concert Night', 3);
INSERT INTO seat (event_id, label)
VALUES
('33333333-3333-3333-3333-333333333333', 'A1'),
('33333333-3333-3333-3333-333333333333', 'A2'),
('33333333-3333-3333-3333-333333333333', 'A3');The functions we'll implement
In a program, you'd have these functions:
Lock the user/event pair, check the configured maximum number of live holds, and take the seat before committing. This example sets the maximum to two so the result is easy to inspect:
BEGIN ISOLATION LEVEL READ COMMITTED;
-- Use the same namespace, UUID order, and hash seed in every hold/refresh path.
SELECT pg_advisory_xact_lock(hashtextextended(
'reservation-hold:' || '33333333-3333-3333-3333-333333333333'::uuid::text
|| ':' || '22222222-2222-2222-2222-222222222222'::uuid::text,
0
));
INSERT INTO reservation (event_id, seat_id, user_id, status, holding_date)
SELECT '33333333-3333-3333-3333-333333333333', 2, '22222222-2222-2222-2222-222222222222', 'H', statement_timestamp()
WHERE (
SELECT count(*) FROM (
SELECT 1 FROM reservation
WHERE event_id = '33333333-3333-3333-3333-333333333333' AND user_id = '22222222-2222-2222-2222-222222222222'
AND status = 'H'
AND holding_date > statement_timestamp() - interval '30 seconds'
LIMIT 2 -- Maximum live holds per user per event. Change both 2s for another limit.
) AS live_holds
) < 2
ON CONFLICT (event_id, seat_id) DO UPDATE
SET user_id = EXCLUDED.user_id,
holding_date = EXCLUDED.holding_date, status = 'H'
WHERE reservation.status = 'H'
AND reservation.holding_date <= statement_timestamp() - interval '30 seconds'
RETURNING *;
COMMIT;BEGIN ISOLATION LEVEL READ COMMITTED;
-- Use the same namespace, UUID order, and hash seed in every hold/refresh path.
SELECT pg_advisory_xact_lock(hashtextextended(
'reservation-hold:' || '33333333-3333-3333-3333-333333333333'::uuid::text
|| ':' || '22222222-2222-2222-2222-222222222222'::uuid::text,
0
));
UPDATE reservation
SET holding_date = statement_timestamp()
WHERE event_id = '33333333-3333-3333-3333-333333333333' AND seat_id = 2 AND user_id = '22222222-2222-2222-2222-222222222222'
AND status = 'H'
AND holding_date > statement_timestamp() - interval '30 seconds'
RETURNING *;
COMMIT;UPDATE reservation
SET status = 'R', reservation_date = statement_timestamp()
WHERE event_id = '33333333-3333-3333-3333-333333333333' AND seat_id = 2 AND user_id = '22222222-2222-2222-2222-222222222222'
AND status = 'H'
AND holding_date > statement_timestamp() - interval '30 seconds'
RETURNING *;SELECT seat.id, seat.label
FROM seat
LEFT JOIN reservation
ON reservation.event_id = seat.event_id AND reservation.seat_id = seat.id
WHERE seat.event_id = '33333333-3333-3333-3333-333333333333'
AND (
reservation.seat_id IS NULL
OR (reservation.status = 'H' AND reservation.holding_date <= statement_timestamp() - interval '30 seconds')
)
ORDER BY seat.id;Holding a seat
Holding is trickier than it looks: we want to grab the seat if it's free, or take it over if the current hold has expired, but never if someone is actively holding or has reserved it. The tempting version is read-then-write — SELECT to check, then INSERT or UPDATE — and that has a gap between the check and the write where another request slips in.
Instead we do it in one statement. The primary key turns the insert into an upsert: if a row already exists, ON CONFLICT DO UPDATE takes over — but only when its WHERE says the existing hold has expired. The unique constraint and the lock on the conflicting reservation protect this seat. This standalone statement doesn't enforce the user's hold limit; the transaction above adds that protection.
EXCLUDED is a pseudo-table Postgres exposes inside DO UPDATE: it holds the row you just tried to insert (the one that hit the conflict), so EXCLUDED.user_id below means "the user_id from the VALUES clause" — whoever is making this hold attempt.
INSERT INTO reservation (event_id, seat_id, user_id, status, holding_date)
VALUES ('33333333-3333-3333-3333-333333333333', 2, '22222222-2222-2222-2222-222222222222', 'H', statement_timestamp())
ON CONFLICT (event_id, seat_id) DO UPDATE
SET user_id = EXCLUDED.user_id, holding_date = statement_timestamp(), status = 'H'
WHERE reservation.status = 'H'
AND reservation.holding_date <= statement_timestamp() - interval '30 seconds'
RETURNING *;If the seat is actively held (not expired), the ON CONFLICT guard fails and the statement changes nothing — zero rows come back, which the app turns into "seat taken". Here Bob tries to grab A2 while Ada is still holding it:
INSERT INTO reservation (event_id, seat_id, user_id, status, holding_date)
VALUES ('33333333-3333-3333-3333-333333333333', 2, '44444444-4444-4444-4444-444444444444', 'H', statement_timestamp())
ON CONFLICT (event_id, seat_id) DO UPDATE
SET user_id = EXCLUDED.user_id, holding_date = statement_timestamp(), status = 'H'
WHERE reservation.status = 'H'
AND reservation.holding_date <= statement_timestamp() - interval '30 seconds'
RETURNING *;But if the previous hold has expired, the exact same statement takes it over cleanly — no delete, no separate check. This time Grace takes over Ada's expired hold:
INSERT INTO reservation (event_id, seat_id, user_id, status, holding_date)
VALUES ('33333333-3333-3333-3333-333333333333', 2, '22222222-2222-2222-2222-222222222222', 'H', statement_timestamp())
ON CONFLICT (event_id, seat_id) DO UPDATE
SET user_id = EXCLUDED.user_id, holding_date = statement_timestamp(), status = 'H'
WHERE reservation.status = 'H'
AND reservation.holding_date <= statement_timestamp() - interval '30 seconds'
RETURNING *;Reserving: confirm only if you still hold it
Reserving is where a naive implementation overbooks. Near the expiry deadline, your hold can lapse and someone else can take the seat while your confirmation is mid-flight. If confirm is a plain UPDATE ... SET status = 'R', you'd hand the seat to a user whose hold is already gone.
So we put every precondition in the WHERE: it's still an H row, still yours, and still within the window at the statement's start. If another writer changes the row while we wait, Postgres checks the updated row against these conditions. A hold that was already expired when this statement began matches nothing:
UPDATE reservation
SET status = 'R', reservation_date = statement_timestamp()
WHERE event_id = '33333333-3333-3333-3333-333333333333' AND seat_id = 2 AND user_id = '22222222-2222-2222-2222-222222222222'
AND status = 'H'
AND holding_date > statement_timestamp() - interval '30 seconds'
RETURNING *;Try the same confirm against a seat someone else holds and it simply matches nothing — zero rows, no double-booking:
UPDATE reservation
SET status = 'R', reservation_date = statement_timestamp()
WHERE event_id = '33333333-3333-3333-3333-333333333333' AND seat_id = 2 AND user_id = '22222222-2222-2222-2222-222222222222'
AND status = 'H'
AND holding_date > statement_timestamp() - interval '30 seconds'
RETURNING *;Listing available seats
A seat is available if it has no reservation row at all, or if its only row is a hold that has already expired. The same 30-second rule that governs holds decides visibility here:
SELECT seat.id, seat.label
FROM seat
LEFT JOIN reservation
ON reservation.event_id = seat.event_id AND reservation.seat_id = seat.id
WHERE seat.event_id = '33333333-3333-3333-3333-333333333333'
AND (
reservation.seat_id IS NULL
OR (reservation.status = 'H' AND reservation.holding_date <= statement_timestamp() - interval '30 seconds')
)
ORDER BY seat.id;Ada is actively holding A2, so it drops out of the list — only A1 and A3 come back. Let a hold expire and the seat quietly reappears, no cleanup required.
When you actually need a lock: advisory locks
The upsert and guarded updates use row locks automatically to protect a single seat. The per-user hold limit spans several seats: two requests can write different reservation rows and never conflict. Putting a count and an insert in one statement alone doesn't prevent that race.
Run it naively and two concurrent requests from the same user both read the same count, both see room under the limit, and both insert. The limit is broken.
BEGIN ISOLATION LEVEL READ COMMITTED;
-- Use the same namespace, UUID order, and hash seed in every hold/refresh path.
SELECT pg_advisory_xact_lock(hashtextextended(
'reservation-hold:' || '33333333-3333-3333-3333-333333333333'::uuid::text
|| ':' || '22222222-2222-2222-2222-222222222222'::uuid::text,
0
));
INSERT INTO reservation (event_id, seat_id, user_id, status, holding_date)
SELECT '33333333-3333-3333-3333-333333333333', 2, '22222222-2222-2222-2222-222222222222', 'H', statement_timestamp()
WHERE (
SELECT count(*) FROM (
SELECT 1 FROM reservation
WHERE event_id = '33333333-3333-3333-3333-333333333333' AND user_id = '22222222-2222-2222-2222-222222222222'
AND status = 'H'
AND holding_date > statement_timestamp() - interval '30 seconds'
LIMIT 2 -- Maximum live holds per user per event. Change both 2s for another limit.
) AS live_holds
) < 2
ON CONFLICT (event_id, seat_id) DO UPDATE
SET user_id = EXCLUDED.user_id,
holding_date = EXCLUDED.holding_date, status = 'H'
WHERE reservation.status = 'H'
AND reservation.holding_date <= statement_timestamp() - interval '30 seconds'
RETURNING *;
COMMIT;Both requests first call pg_advisory_xact_lockwith the same key for this user/event pair. The second request waits until the first commits or rolls back, releasing its lock. At READ COMMITTED, its subsequent statement gets a fresh snapshot that includes the first request's committed hold. Acquiring the lock in a CTE inside the count statement would retain a snapshot from before the wait.
The check and seat insert must finish in this same transaction, on the same connection. Committing after the count and inserting afterward releases the lock too early. A zero-row insert means either the user is at the limit or the seat is unavailable; report success only after commit.
This is a coordination rule: an advisory lock does not automatically lock reservations. Every path that creates a hold or extends its expiry must acquire the same lock first. That's why the refresh operation above also uses it. Confirming a reservation only reduces the number of holds, so its guarded update can stand alone.
Locking the user row would also queue that user's requests for unrelated events, and FOR UPDATEconflicts with foreign-key checks referencing that user. The advisory lock coordinates the user/event pair without a persistent lock row. Use the transaction-scoped function shown here: a session-scoped advisory lock would survive commit and rollback until explicitly released or the connection closes.
Our IDs are UUIDs, while this advisory-lock function accepts a 64-bit integer. hashtextextended hashes a namespaced string containing the event UUID followed by the user UUID, with seed zero. Casting through uuid::text gives a canonical spelling. Every caller must use this same key recipe. A hash collision makes unrelated pairs wait for each other; it does not allow two requests for the same pair to bypass the lock.
Keep the explicit READ COMMITTED setting. An advisory lock does not refresh a transaction's snapshot under REPEATABLE READ, so changing the isolation level would invalidate this count-and-insert strategy.
The index on (event_id, user_id, holding_date), restricted to status = 'H', supports the live-hold lookup. The example's maximum is two, so LIMIT 2inside the counted subquery stops after two matches. For a maximum of 100, use LIMIT 100 and compare the count with 100. The expiry cutoff belongs in the query: a partial index cannot automatically remove entries as time passes.
The lock makes concurrent holds and refreshes for the same user/event pair take turns. Pairs with different lock keys can proceed independently, with the reservation's unique key still resolving competition for the same seat. Atomicity, advisory locks, row locks, and statement snapshots each do a different part of the work.
This is the shape every remaining hard case takes — postponing an expiry, confirming right at the deadline, any "check several rows then decide." It's also exactly what Redis struggles with: its GET/SET pairs aren't atomic, and its handful of compare-and-set operators can't express "read these rows under a lock, then write." In Postgres it's one clause.
To implement this in Redis, you need a Lua script or Redis Function to make the multi-key check and write atomic.
What We Learned
- Primary key (event_id, seat_id)one reservation row per seat, so two people can never both book the same seat — the double-booking invariant holds by construction.
- INSERT ... ON CONFLICT DO UPDATEupsert: take the seat if free, or take over an expired hold — atomically, in one statement, with no read-then-write gap.
- EXCLUDEDthe pseudo-table holding the row you tried to insert, available inside DO UPDATE — lets the update refer back to the values you were writing.
- Guarded UPDATEchecking ownership and status in the WHERE clause protects a single seat; UPDATE takes a row lock automatically.
- Expiry as a rulea hold counts only while holding_date is newer than the window; nothing has to delete it on a timer.
- pg_advisory_xact_lockcoordinates holds and refreshes for the same user/event pair; the lock releases automatically on commit or rollback.
- Atomicitythe transaction commits all its changes or none; isolation and locking determine how concurrent transactions interact.