Rashid Ali
RASHID ALI
RashidBuilds
How to Prevent Double Bookings in PostgreSQL with Transactions and Row-Level Locks
Back to Essays
AUTHORRashid Ali
CATEGORY // TIME
Backend12 min read

How to Prevent Double Bookings in PostgreSQL with Transactions and Row-Level Locks

PUBLISHED // Oct 2026
(Scroll down)
(Intro)

Building a booking or reservation system seems deceptively straightforward in local testing: an API checks whether a resource is available, verifies that no conflicting records exist, and inserts a new reservation. But the moment your application faces concurrent users clicking 'Reserve' within the same 50-millisecond window, the classic Time-of-Check to Time-of-Use (TOCTOU) race condition strikes. Two transactions both see the slot as free, both commit their inserts, and your system records a double booking.

Attempting to fix double bookings in application code—using distributed locks, Redis flags, or in-memory mutexes—adds immense operational complexity while failing to provide absolute guarantees across multi-instance deployments. PostgreSQL provides battle-tested concurrency primitives built directly into its storage engine: transaction isolation levels, explicit row-level locking (SELECT ... FOR UPDATE), and the gold standard for continuous time intervals: GiST exclusion constraints with the btree_gist extension. Here is how to engineer race-condition-proof booking systems in PostgreSQL with mathematical certainty.

Summary

The TOCTOU Flaw in Application-Level Checks

Most developers write their initial booking logic as an imperative two-step sequence in their API route: first, query the database to verify that no overlapping reservation exists for the requested resource; second, if the query returns zero rows, execute an INSERT statement.

Under concurrent execution, this architecture produces the classic Time-of-Check to Time-of-Use (TOCTOU) race condition. Imagine User A and User B requesting the same room for the same time slot simultaneously. Thread A issues a SELECT query and finds no conflicts. Before Thread A can issue its INSERT, Thread B executes its own SELECT query—which also returns zero rows, because Thread A has not committed anything yet.

Both threads proceed with their inserts. Under PostgreSQL's default READ COMMITTED transaction isolation level, neither transaction blocks the other. Both transactions commit successfully, creating an embarrassing and expensive double booking. The flaw is architectural: checking in application memory cannot guarantee consistency across concurrent stateless API replicas.

ENGINEERING CHECKLIST & GUIDELINES
  • Never rely on application-level 'check-then-insert' logic to guarantee resource exclusivity under concurrent load.
  • Understand that default READ COMMITTED isolation allows concurrent transactions to read the same empty slot simultaneously.
  • Enforce concurrency constraints at the database storage engine level, not in ephemeral stateless API workers.

PostgreSQL Transaction Isolation Levels & Phantom Inserts

A common misconception among backend engineers is that wrapping the check-and-insert logic inside a standard BEGIN ... COMMIT transaction block solves concurrency issues. It does not.

In PostgreSQL's default READ COMMITTED mode, each query inside a transaction sees a snapshot of data committed before that specific query began, not when the transaction started. Wrapping statements in BEGIN and COMMIT does not acquire any locks on non-existent rows.

Switching to REPEATABLE READ also fails to prevent double bookings into empty time ranges. While REPEATABLE READ prevents non-repeatable reads on existing rows, concurrent transactions can still insert new rows into empty spaces without conflicting—the classic 'Phantom Insert' scenario. While full SERIALIZABLE isolation does detect read/write dependencies using Serializable Snapshot Isolation (SSI) and aborts conflicting transactions with error 40001 (serialization_failure), it requires complex application-level retry loops with exponential backoff and degrades under heavy write contention.

ENGINEERING CHECKLIST & GUIDELINES
  • Recognize that REPEATABLE READ prevents non-repeatable reads on existing rows, but does not block phantom inserts into empty ranges.
  • Use SERIALIZABLE isolation only when paired with automatic application retry handlers to handle serialization_failure (40001).
  • Balance isolation levels against connection pool saturation and lock table overhead under peak traffic.

Pessimistic Locking with SELECT FOR UPDATE

When resources are modeled as discrete, pre-allocated inventory units—such as specific airline seats (12A, 12B), numbered hotel rooms, or pre-seeded 30-minute calendar slots in a schedule table—pessimistic row-level locking provides a high-performance solution.

Using SELECT ... FOR UPDATE within a transaction instructs PostgreSQL to acquire an exclusive lock on the selected row. Any concurrent transaction attempting to read that row FOR UPDATE, or attempting to UPDATE or DELETE it, is forced to pause and wait until the first transaction commits or rolls back.

To prevent API threads from hanging indefinitely behind congested locks, production systems leverage the NOWAIT clause: SELECT id FROM slots WHERE id = $1 FOR UPDATE NOWAIT. If another transaction holds the lock, PostgreSQL aborts immediately with error code 55P03 (lock_not_available), allowing your API to return an immediate HTTP 409 Conflict without holding database connection pool slots hostage.

ENGINEERING CHECKLIST & GUIDELINES
  • Use SELECT ... FOR UPDATE when inventory is pre-seeded as discrete rows (seats, calendar slots, equipment).
  • Leverage NOWAIT to fail fast with HTTP 409 Conflict instead of holding connection pool threads in deep lock queues.
  • Always acquire locks in a consistent primary key order across transactions to avoid mutual deadlocks (40P01).

The Gold Standard: GiST Exclusion Constraints & tstzrange

Pessimistic row locking breaks down when reservations represent continuous, arbitrary time windows rather than discrete slots. For example, in a car rental, conference room, or equipment booking application, a customer might reserve a resource from 14:15 to 16:45 on Tuesday. You cannot execute SELECT ... FOR UPDATE on a booking row that does not yet exist.

Standard SQL UNIQUE constraints fail here because they only evaluate exact equality (=); they cannot detect overlapping ranges. PostgreSQL solves this with a world-class feature: Exclusion Constraints using GiST (Generalized Search Tree) indexes and range types.

By enabling the btree_gist extension, you can create a constraint combining scalar equality with range overlap. Using the tstzrange (timestamp with time zone range) data type and the overlap operator (&&), the database kernel enforces that no two active rows for the same resource can have overlapping time intervals. This guarantee holds true across all isolation levels, under any level of concurrency, with zero race condition windows.

migrations/create_reservations_table.sql
sql
-- 1. Enable btree_gist extension to mix scalar columns (UUIDs) with range types
CREATE EXTENSION IF NOT EXISTS btree_gist;
 
-- 2. Create the room reservations table with a partial exclusion constraint
CREATE TABLE room_reservations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
room_id UUID NOT NULL,
customer_id UUID NOT NULL,
booking_range TSTZRANGE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'CONFIRMED',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Enforce: No two reservations for the same room can overlap unless cancelled
CONSTRAINT no_overlapping_active_bookings
EXCLUDE USING gist (
room_id WITH =,
booking_range WITH &&
) WHERE (status != 'CANCELLED')
);
 
-- Example: Check for overlapping bookings using the && operator
SELECT id, room_id, booking_range
FROM room_reservations
WHERE room_id = 'c3a9f0e2-8e12-4c6e-9b2a-1f8a7e4b9d01'
AND booking_range && tstzrange('2026-10-15 14:00:00+00', '2026-10-15 16:00:00+00');
ENGINEERING CHECKLIST & GUIDELINES
  • Install btree_gist to enable combining scalar columns (UUIDs, integers) with range types in exclusion indexes.
  • Use the tstzrange data type to represent continuous start and end timestamps as a single atomic value.
  • Enforce EXCLUDE USING gist (resource_id WITH =, booking_range WITH &&) to block overlaps at the database kernel level.

Optimistic Concurrency Control with Version Columns

In high-read, low-write scenarios where reservation contention is rare, acquiring pessimistic row locks or holding database locks during external third-party operations (such as processing a Stripe payment modal) wastes connection pool resources.

Optimistic Concurrency Control (OCC) assumes conflicts are infrequent. The database table includes a version integer column. When the application reads the record, it records the current version number. When the user submits their reservation, the mutation executes as an atomic conditional update: UPDATE inventory SET reserved_count = reserved_count + 1, version = version + 1 WHERE id = $1 AND version = $expected_version AND reserved_count < capacity.

If a concurrent transaction committed a change in the interim, the version will not match, and PostgreSQL updates zero rows. The application detects the zero-row return and prompts the user or retries gracefully without ever deadlocking or corrupting inventory state.

ENGINEERING CHECKLIST & GUIDELINES
  • Adopt optimistic concurrency control for low-contention environments where holding pessimistic locks exhausts connection pools.
  • Use atomic UPDATE ... WHERE version = expected_version checks to detect concurrent modifications without explicit locks.
  • Handle zero-row update returns by failing fast with a clean client notification or executing an automatic retry.

Production Implementation & Error Handling in TypeScript

When building production booking APIs in Node.js or TypeScript (using pg, Prisma, or Drizzle), you must properly format time ranges and handle PostgreSQL constraint violations.

Time intervals should always be formatted using half-open range notation: [start, end). The bracket '[' means inclusive of the start second, while the parenthesis ')' means exclusive of the end second. This standard allows back-to-back reservations (e.g. 14:00 to 15:00 and 15:00 to 16:00) to coexist seamlessly without colliding at 15:00:00.

When a concurrent collision occurs, PostgreSQL rejects the conflicting transaction and returns SQL state code 23P01 (exclusion_violation). Your API must intercept this specific error code and return a clean HTTP 409 Conflict status with a structured error payload, rather than letting an unhandled 500 Internal Server Error escape to the client.

services/bookingService.ts
typescript
import { Pool } from "pg";
 
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
 
export interface CreateBookingParams {
roomId: string;
customerId: string;
startTime: Date;
endTime: Date;
}
 
export interface BookingResult {
success: boolean;
bookingId?: string;
error?: "SLOT_ALREADY_BOOKED" | "DATABASE_ERROR";
}
 
export async function createReservation(params: CreateBookingParams): Promise<BookingResult> {
const { roomId, customerId, startTime, endTime } = params;
 
// Format as half-open range: [start, end)
// Inclusive start, exclusive end permits back-to-back bookings (e.g. 14:00-15:00 and 15:00-16:00)
const rangeString = `[${startTime.toISOString()}, ${endTime.toISOString()})`;
 
const client = await pool.connect();
try {
const query = `
INSERT INTO room_reservations (room_id, customer_id, booking_range)
VALUES ($1, $2, $3::tstzrange)
RETURNING id;
`;
 
const res = await client.query(query, [roomId, customerId, rangeString]);
return {
success: true,
bookingId: res.rows[0].id,
};
} catch (err: any) {
// 23P01 is PostgreSQL's specific error code for exclusion_violation
if (err.code === "23P01") {
console.warn(`[BookingConflict] Room ${roomId} already reserved for window: ${rangeString}`);
return {
success: false,
error: "SLOT_ALREADY_BOOKED",
};
}
 
console.error("[BookingError] Unexpected database error during insert:", err);
return {
success: false,
error: "DATABASE_ERROR",
};
} finally {
client.release();
}
}
ENGINEERING CHECKLIST & GUIDELINES
  • Format continuous timestamps using ISO-8601 strings with half-open range notation: '[start, end)'.
  • Catch PostgreSQL error code 23P01 (exclusion_violation) specifically and map to HTTP 409 Conflict.
  • Combine exclusion constraints with soft-delete filtering by incorporating a partial index condition (WHERE status != 'CANCELLED').

Key Architectural Takeaways

Double bookings are a symptom of attempting to solve database-tier concurrency problems in stateless application servers. By offloading resource exclusivity to PostgreSQL's native GiST exclusion constraints and row-level locking primitives, your architecture remains bulletproof under any volume of concurrent traffic.

KEY ARCHITECTURAL TAKEAWAYS
Never rely on application-level 'check-then-insert' logic; concurrent transactions bypass application checks under default READ COMMITTED.
Use SELECT ... FOR UPDATE NOWAIT when locking pre-seeded discrete inventory units (seats, numbered rooms).
Deploy GiST exclusion constraints with btree_gist and tstzrange to prevent overlapping continuous time reservations.
Format time ranges with half-open notation '[start, end)' to support adjacent back-to-back bookings seamlessly.
Add partial index clauses 'WHERE (status != 'CANCELLED')' so cancelled reservations release time windows automatically.
Intercept PostgreSQL error code 23P01 (exclusion_violation) in your application layer and map it cleanly to HTTP 409 Conflict.
#PostgreSQL#System Design#SQL#Database Concurrency#Backend#Transactions

READY TO MOVE SMARTER?

We are here to help you grow without hassle.
No call centres. No runaround. Just experienced people ready to help.