How to Prevent Double-Booking: Make It Impossible, Not Unlikely
To prevent double-booking, you first have to accept an uncomfortable truth: most booking bugs are not booking bugs. They are trust bugs.
Two people grab the same slot. Both get a confirmation email. Now someone has to apologize, refund, or rebook, and your product looks unreliable for a reason that has nothing to do with your product. The fix everyone reaches for first is an application-level check. Read the calendar, see if the slot is free, then write the booking. It reads like correct code. It fails the moment two people click at once.
This post is about why that check fails, and how to move the guarantee somewhere it cannot fail: into the database itself.
The short version: An app-level “is this slot free?” check has a gap between the read and the write. Under concurrent load, two requests slip through that gap and double-book the same slot. A Postgres exclusion constraint closes the gap by refusing the overlapping row at write time, so the bug stops being unlikely and becomes impossible.
Why app-level availability checks fail under load
The flaw is the gap between the read and the write.
Picture the standard approach. A request comes in, your code queries the bookings table, sees the 3pm slot is open, and inserts a new row. Perfectly reasonable. Now picture two requests arriving in the same handful of milliseconds:
Request A --read--> "3pm is free"
Request B --read--> "3pm is free" (A hasn't written yet)
Request A --write-> books 3pm OK
Request B --write-> books 3pm OK <- double-booked
Both reads happened before either write. The check passed for both. The slot is now booked twice, and nothing in your code did anything “wrong” in isolation. Each request followed the rules. The rules just had a hole in them.
You can make that hole smaller. You can lower latency, add a transaction, narrow the window between the read and the write. What you cannot do is close it in application code, because there is always a slice of time, however thin, where one request has checked but not yet written. Something can always slip into that slice.
So I stopped guarding the slot in the app. I pushed the guard down to the database, where the write itself can be refused.
Pushing the rule into the database
PostgreSQL has a feature built for exactly this: the exclusion constraint. Instead of asking the app to check for overlaps, you declare in the table definition that overlaps are not allowed, and the database enforces it on every insert.
First you need the building blocks. A booking has a resource (the room, the person, the rental) and a time range, and you want the btree_gist extension so the constraint can mix an equality check with a range-overlap check:
create extension if not exists btree_gist;
create table bookings (
id bigint generated always as identity primary key,
resource_id bigint not null,
during tstzrange not null,
-- No two bookings for the same resource can overlap in time.
constraint no_overlap
exclude using gist (
resource_id with =,
during with &&
)
);
That exclude using gist (...) line is the whole game. It tells Postgres: for any two rows that share the same resource_id, their during ranges must not overlap (&& is the range-overlap operator). This is not “the app should check.” The database will refuse to write the second row.
Now an insert is just intent. The database is the one that says yes or no:
-- Books resource 42 from 3:00 to 4:00pm:
insert into bookings (resource_id, during)
values (42, '[2026-06-01 15:00, 2026-06-01 16:00)');
-- A second, overlapping booking for the same resource:
insert into bookings (resource_id, during)
values (42, '[2026-06-01 15:30, 2026-06-01 16:30)');
-- ERROR: conflicting key value violates exclusion constraint "no_overlap"
The overlap is rejected at write time, by the system that owns the data, with no gap for a second request to exploit. Notice the half-open range too, [15:00, 16:00). A booking that ends exactly at 4:00 does not collide with one that starts at 4:00, which is almost always the behavior you want for back-to-back slots.
What this changes about what you have to trust
This is the part that matters more than the syntax. Moving the rule into the database changes what you are forced to trust.
With an app-level check, you are trusting that every code path that ever writes a booking remembered to run the check first. The web handler, the admin tool, the bulk importer, the cron job, the migration script someone runs by hand at midnight. Miss the check in any one of them and the hole reopens. You are also trusting that two requests will never truly arrive at once, which is a bet you lose the day you get popular.
With the constraint, you trust one thing: the rule on the table. Every path that writes a booking obeys it whether it meant to or not. The app expresses what it wants to happen. The database refuses anything that breaks the rule. Your handler still catches the error and shows the user a friendly “that slot was just taken, here are nearby times” message, but that is presentation, not protection. Delete that handling tomorrow and the data is still correct.
Put the rule where the data lives
This is a habit, not a one-off trick. The pattern shows up across everything I build: put the rule where the data lives, not where the code happens to touch it.
A few examples of the same idea:
| The rule | The weak way (in code) | The strong way (on the data) |
|---|---|---|
| Emails must be unique | SELECT before INSERT to check | UNIQUE constraint |
| Bookings can’t overlap | Read the calendar, then write | EXCLUDE constraint |
| A status only moves forward | A comment that asks nicely | A CHECK constraint or trigger |
| A child row needs a parent | Hope the app inserts in order | FOREIGN KEY |
Every row in that left column is a rule enforced by discipline, which means a rule enforced by memory, which means a rule that eventually breaks. Every row on the right is enforced by the database, the one component that sees every write no matter where it came from.
Making the bug impossible, not unlikely
The cost here is small and the payoff compounds. You write a few lines of schema once. In return, an entire category of bug becomes structurally impossible instead of merely unlikely.
That distinction is the whole point. Unlikely is a bug that ships and waits, quiet on a slow Tuesday and then firing the day a flash sale doubles your traffic. Impossible is a bug that cannot be written, because the database will not accept the row that represents it. One of those wakes you up at 3am. The other never gets the chance.
When a client asks whether double-booking can happen, the honest answer is not “we check for it.” Checking is a thing you do, and things you do can be skipped, forgotten, or raced. The honest answer is “the database will not allow it.” That is a different kind of confidence, and it is the kind worth building on.
Frequently asked questions
What causes a double-booking bug?
Almost always a race condition between reading availability and writing the booking. Two requests check the same slot at nearly the same moment, both see it as free because neither has written yet, and both then insert a booking. The check passed for both, so the slot ends up booked twice.
Why doesn’t an application-level availability check prevent double-booking?
Because there is always a gap between the moment your code reads “this slot is free” and the moment it writes the new row. Under concurrent load, a second request can slip into that gap. You can shrink the window with transactions and lower latency, but you cannot eliminate it in application code alone.
What is a PostgreSQL exclusion constraint?
It is a table-level rule that prevents rows from coexisting if they conflict according to operators you define. For booking systems you typically combine = on the resource with && (range overlap) on a time range, so Postgres rejects any new booking that overlaps an existing one for the same resource. It requires the btree_gist extension to mix the equality and overlap checks.
Do I still need to handle the error in my application code?
Yes, but only for user experience, not for correctness. When the constraint rejects an insert, catch the error and show a helpful message like “that time was just booked.” The data stays correct even if you forget to do this. The constraint is the guarantee; the error handling is the polish.
Does a database constraint hurt booking performance?
Exclusion constraints are backed by a GiST index, so checking for overlaps is fast and scales well. The cost is negligible next to the alternative: an unbounded category of correctness bugs and the manual cleanup, refunds, and lost trust that follow each one.