DEV Community

Cover image for How to Prevent Double Booking: Locks, Holds and Expiry
Muhammad Ali Mughal
Muhammad Ali Mughal

Posted on

How to Prevent Double Booking: Locks, Holds and Expiry

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.

Rack-mounted database servers with blue and green status lights in a small data center

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', '[...]');
Enter fullscreen mode Exit fullscreen mode

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}",))
Enter fullscreen mode Exit fullscreen mode

Result on my box:

Test 1: naive check-then-insert, one slot, 20 concurrent requests
  active bookings: 14 {'ok': 20}
Enter fullscreen mode Exit fullscreen mode

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'));
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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}",))
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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.

systemdesign #postgresql #database #backend #laravel #architecture

Top comments (2)

Collapse
 
launchgatecheck profile image
Launch Gate •

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.

Collapse
 
muhammadalimughal profile image
Muhammad Ali Mughal •

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.