Skip to content
Aldridge Dagos Get in touch

N°010 · 2026.05.29 · 7 MIN

How to Prevent Double-Booking: Make It Impossible, Not Unlikely

By Aldridge Dagos, operations software engineer


One cell glowing on a dark calendar grid, the other dates dim around it.
A PostgreSQL exclusion constraint can reject overlapping bookings for the same resource.

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: A naive “is this slot free?” query followed by an insert has a race under concurrency. A PostgreSQL exclusion constraint can reject rows whose resource and time range conflict, regardless of which application path writes them. That is often the cleanest control for a no-overlap rule. Application code can also preserve correctness with appropriate row or advisory locks, or a correctly retried serializable transaction. Keep the pre-check for user experience, but rely on a concurrency-safe database mechanism for the final decision.

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.

Lower latency only makes the race less frequent. A transaction by itself does not fix it under every isolation level. You can close the race with a database constraint, appropriate locking, or serializable isolation with retry handling. The important distinction is between a naive pre-check and a concurrency-safe database protocol.

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 this rule: the exclusion constraint. Instead of relying on a separate availability query, you declare which row combinations may not coexist. PostgreSQL’s constraint documentation explains that an exclusion constraint guarantees that not all specified comparisons can be true for two rows.

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, an AI caller booking a lead mid-conversation, 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 overlapping rows invalid

The schema makes an overlapping active-booking row invalid for the resource and range modeled by the constraint. That is a precise guarantee. It still depends on modeling status, range boundaries, time zones, buffers, resource identity, and any administrative override correctly.

The distinction is the whole point. A pre-check estimates availability before the write. A constraint decides whether the row may exist at the write. The application can still show available times first, then catch a conflict and offer alternatives if another request wins the slot.

When a client asks how double-booking is prevented, the useful answer names the enforced invariant: the database rejects overlapping active bookings for the same resource. Then explain the boundaries and the conflict experience.

I built this rule into a booking system with database-enforced overlap protection.

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 a separate read and write can race under concurrency. Lower latency does not guarantee correctness. A constraint, appropriate locking, or a correctly retried serializable transaction can make the final decision safely.

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. Catch the conflict and show a useful message such as “that time was just booked” with nearby alternatives. The constraint protects the modeled data invariant, while application handling protects the customer experience and preserves expected transaction behavior.

Does a database constraint hurt booking performance?

Exclusion constraints create an index for the specified access method, but performance depends on table size, write volume, range distribution, and query patterns. Benchmark representative concurrent inserts and availability queries before setting capacity assumptions.