By Muhammad Ali Mughal · Last updated 24 October 2026
Two customers click "Book" on the last slot at the same moment. Both see a confirmation. One of them shows up to find the truck, room or appointment already taken. If you want to prevent double booking, checking availability in your code before inserting isn't enough. The database has to enforce the rule, using a constraint, a row lock or both, and short-lived holds need an expiry so abandoned checkouts don't block real customers.
I'm Muhammad Ali Mughal, and at Loop Origin we build booking and operations software, including a Car Carrier Management System for a vehicle transport business in Pakistan, built on Laravel and PostgreSQL. In this guide I'll show you a race condition I reproduced on PostgreSQL 17, then three fixes and the test results for each.
Why "check, then insert" double-books
The usual first version looks like this:
SELECT count(*) FROM bookings
WHERE carrier_id = 1 AND during && '[2026-10-25 09:00+05, 2026-10-25 17:00+05)';
-- if 0, then:
INSERT INTO bookings (carrier_id, customer, during) VALUES (1, 'c1', '[...]');
Under load, two requests can both run the SELECT, both see zero, and both INSERT. Nothing in the database says those rows conflict, so both commits succeed. This is a classic time-of-check to time-of-use race.
The test: 20 concurrent requests for one slot
I wrote a Python script using psycopg and 20 threads. Each thread opens its own connection, waits on a barrier so they all fire together, then tries to book carrier 1 for the same 8-hour window. Here's the naive version:
def naive(c, i):
n = c.execute(f"SELECT count(*) FROM bookings WHERE carrier_id=1 AND during && {SLOT}").fetchone()[0]
if n == 0:
c.execute(f"INSERT INTO bookings (carrier_id, customer, during) VALUES (1, %s, {SLOT})", (f"c{i}",))
Result on my box:
Test 1: naive check-then-insert, one slot, 20 concurrent requests
active bookings: 14 {'ok': 20}
Fourteen bookings for a slot that should hold one, and every request reported success. The exact number changes from run to run, which is what makes this bug so hard to catch in manual testing.
Fix 1: Prevent double booking with an exclusion constraint
For "one booking per resource per time range", PostgreSQL has a direct answer: an exclusion constraint over a range type. It rejects any row whose range overlaps an existing row for the same carrier. The btree_gist extension lets you mix plain equality on carrier_id with the range overlap operator.
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT no_overlap
EXCLUDE USING gist (carrier_id WITH =, during WITH &&)
WHERE (status IN ('held', 'confirmed'));
The WHERE clause makes it a partial constraint, so cancelled or expired bookings don't block the slot. Same 20 threads, now just inserting:
Test 2: exclusion constraint on (carrier_id, during)
active bookings: 1 {'ok': 1, 'ExclusionViolation': 19}
Exactly one booking. The other 19 got error 23P01 (exclusion violation), which your app can catch and turn into "Sorry, that slot was just taken".
If your slots are fixed (for example 30-minute appointments), a plain UNIQUE (resource_id, slot_start) constraint does the same job and works in MySQL too.
Fix 2: Lock the parent row when capacity is more than one
A car carrier has room for several vehicles, so the rule isn't "no overlaps", it's "no more than N". A constraint can't count rows, but a row lock can serialise the check. Lock the carrier row with SELECT ... FOR UPDATE, count, then insert, all in one transaction:
def capacity_lock(c, i):
cap = c.execute("SELECT capacity FROM carriers WHERE id=1 FOR UPDATE").fetchone()[0]
used = c.execute(f"SELECT count(*) FROM bookings WHERE carrier_id=1 AND during && {SLOT} "
"AND status IN ('held','confirmed')").fetchone()[0]
if used >= cap:
raise psycopg.errors.CheckViolation("full")
c.execute(f"INSERT INTO bookings (carrier_id, customer, during, status, expires_at) "
f"VALUES (1, %s, {SLOT}, 'held', now() + interval '10 minutes')", (f"c{i}",))
With capacity set to 8:
Test 3: capacity 8 (car slots on one carrier), SELECT ... FOR UPDATE on the carrier row
active bookings: 8 {'ok': 8, 'CheckViolation': 12}
Eight in, twelve refused. The lock makes each transaction wait for the previous one to commit before counting, as described in the PostgreSQL docs on explicit locking. Keep these transactions short, because everyone booking that carrier queues behind the lock.
In Laravel, the same pattern is DB::transaction() with Carrier::whereKey($id)->lockForUpdate()->first() before the count.
Fix 3: Holds with expiry
Notice that Test 3 inserted rows as held with expires_at = now() + 10 minutes. That's the pattern for checkout flows: reserve the slot while the customer pays, then confirm or release it.
A hold that never expires is just another double-booking bug in slow motion. A scheduled job releases stale holds:
UPDATE bookings SET status = 'expired'
WHERE status = 'held' AND expires_at < now();
To test it, I back-dated three holds, ran the sweeper, confirmed one hold as paid, and fired a second wave of 20 requests:
Test 4: expire stale holds, then capacity frees up
holds expired: 3
second wave of 20 requests: 8 {'ok': 3, 'CheckViolation': 17}
Three slots freed, three new holds taken, total still capped at 8. Your availability query should also ignore expired holds directly (expires_at > now()), so a late sweeper never shows a slot as full when it isn't.
In Laravel I run the sweeper from the scheduler every minute, and payment callbacks go through queued jobs that confirm a hold only if it's still held. I wrote about running those workers reliably in Running Laravel Queues in Production.
Which fix to use
| Situation | Fix | Works in |
|---|---|---|
| One booking per resource per time range | Exclusion constraint on a range | PostgreSQL |
| Fixed slots (e.g. 30-minute appointments) | Unique constraint on resource + slot start | PostgreSQL, MySQL, SQLite |
| Capacity greater than one (seats, car slots, stock) |
SELECT ... FOR UPDATE on the parent row, or a conditional UPDATE ... SET remaining = remaining - 1 WHERE remaining > 0
|
PostgreSQL, MySQL |
| Checkout or payment step in between | Holds with expires_at plus a sweeper |
Any |
The conditional UPDATE is worth knowing: it checks and decrements in one atomic statement, so there's no gap between check and write. The same idea protects stock counts in online stores, which I touched on in my guide to launching an online store in Pakistan.
Double booking prevention checklist
- The database enforces the rule (constraint or lock), not only app code
- Time ranges use half-open intervals
[start, end)so back-to-back bookings don't clash - Conflicts return a friendly "slot just taken" message, not a 500 error
- Holds have an expiry, and a scheduled job releases them
- Availability queries ignore expired holds
- Payment callbacks confirm a hold only if it's still active
- A concurrency test fires parallel requests at the same slot
FAQ
How do you prevent double booking in a database?
Make the database enforce the rule. Use a unique constraint for fixed slots, an exclusion constraint for overlapping time ranges in PostgreSQL, or a row lock with SELECT ... FOR UPDATE when a resource has capacity above one. Checking availability in application code first, without a lock, still allows two requests to slip through.
Is a unique index enough to stop double bookings?
It's enough when bookings map to fixed slots, such as one row per resource per 30-minute start time. It isn't enough for free-form time ranges, because two different start times can still overlap. For ranges, use a PostgreSQL exclusion constraint, or lock the resource row before checking for overlaps.
What is the difference between optimistic and pessimistic locking for bookings?
Pessimistic locking takes a lock first, so other requests wait, as with SELECT ... FOR UPDATE. Optimistic locking lets everyone proceed and rejects the losers at write time, using a version column or a constraint violation. Optimistic suits low-contention bookings; pessimistic suits popular slots where many people compete for the same resource.
How long should a booking hold last?
Long enough for a normal checkout, and no longer. Ten to fifteen minutes is a common choice for card payments, while a phone or bank transfer flow may need more. Store expires_at on the hold, ignore expired holds in availability queries, and run a sweeper that marks them expired.
Can MySQL prevent overlapping bookings?
MySQL has no exclusion constraints, so you need another approach. Use fixed slots with a unique index, or lock the resource row with SELECT ... FOR UPDATE inside a transaction before checking for overlaps. Both work with InnoDB. Test either approach with parallel requests, not just one browser tab.
Need a booking system built?
If you're planning a booking, dispatch or reservation platform, see how we handled bookings, dispatch and accounting in the Car Carrier Management System, or visit Loop Origin.
Have you been bitten by a double-booking bug in production? Tell me how you found it in the comments.

Top comments (2)
The fourteen-for-one result is the sentence to quote to anyone who thinks check-then-insert is fine: every request reported success, so the failure is invisible until someone shows up to a taken truck.
One refinement on the hold handling: make the confirmation step itself conditional, not just the sweeper and the availability query. UPDATE bookings SET status='confirmed' WHERE id=$1 AND status='held' AND expires_at > now(), then treat zero rows updated as "hold lapsed, re-book". Without that, a payment callback arriving after expiry can confirm a booking whose slot was re-taken by the second wave - a double booking with a paid receipt attached. Your Test 4 flow implies it, but pinning it as a fixture (confirm-after-expiry must fail) is what keeps it true.
Also worth knowing: the partial exclusion constraint only covers the statuses you remembered to list. A new status added later that should still block the slot silently falls outside the constraint. A test that inserts every status value and asserts the right ones conflict catches that on the day the status is added, not the day the truck is missing.
Thanks, both points are spot on. The confirm step should carry the same guard as everything else:
UPDATE bookings SET status = 'confirmed' WHERE id = $1 AND status = 'held' AND expires_at > now(), and zero rows back means the hold lapsed, so the job refunds or re-books instead of confirming. A late payment callback is exactly the case that slips through otherwise, and I like pinning confirm-after-expiry as its own fixture. The status list is a good catch too: a new status that should block the slot quietly falls outside the constraint's WHERE clause, so a test that walks every status value and checks which ones conflict is cheap insurance.