While working on a side project, a scheduling tool, I hit a rule that turned out to be deceptively hard to enforce in SQL: two appointments for the same practitioner cannot overlap in time, unless the user explicitly marks one as an overbook.
PostgreSQL 18 introduced WITHOUT OVERLAPS on unique constraints and PERIOD on foreign keys. This change moved two important business rules from application code into the database schema.
Here’s what changed in our TypeScript booking endpoint when we upgraded from PG 17 to PG 18.
How we did it in PG 17
We had a complex EXCLUDE USING GIST constraint:
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE appointments
ADD CONSTRAINT appointments_no_overlap
EXCLUDE USING GIST (
practitioner_id WITH =,
tstzrange(start_time, end_time, '[)') WITH &&
)
WHERE (
status NOT IN ('cancelled', 'no_show')
AND parent_appointment_id IS NULL
AND is_overbook = FALSE
);
It worked, but it came with real pain:
- Required the
btree_gistextension - Used a strange error code (
23P01) - Needed a manual backfill during migration
- Was hard to read and maintain
- The second rule (“appointment must be inside the practitioner’s availability”) had no database enforcement at all
How it looks in PG 18
-- 1. Practitioner availability windows
CREATE TABLE availability (
practitioner_id UUID NOT NULL,
valid_during tstzrange NOT NULL,
PRIMARY KEY (practitioner_id, valid_during WITHOUT OVERLAPS)
);
-- 2. Add a generated range column
ALTER TABLE appointments
ADD COLUMN during tstzrange
GENERATED ALWAYS AS (tstzrange(start_time, end_time, '[)')) STORED;
-- 3. Overlap constraint (only for normal appointments)
ALTER TABLE appointments
ADD CONSTRAINT appointments_no_overlap
UNIQUE (practitioner_id, during WITHOUT OVERLAPS)
WHERE (
status NOT IN ('cancelled', 'no_show')
AND parent_appointment_id IS NULL
AND is_overbook = FALSE
);
-- 4. Temporal foreign key: appointment must fall inside availability
ALTER TABLE appointments
ADD CONSTRAINT appointments_inside_availability
FOREIGN KEY (practitioner_id, PERIOD during)
REFERENCES availability (practitioner_id, PERIOD valid_during);
No extension. No GiST. No weird error codes.
The booking handler (TypeScript)
Before (PG 17) — ~40 lines
We needed three queries inside a SERIALIZABLE transaction, plus custom error handling for the strange 23P01 code.
After (PG 18) — ~12 lines
export async function bookAppointment(req: BookingRequest): Promise<void> {
try {
await sql`
INSERT INTO appointments (id, practitioner_id, start_time, end_time, is_overbook)
VALUES (
${req.id},
${req.practitionerId},
${req.startTime},
${req.endTime},
${req.isOverbook}
)
`;
} catch (e: any) {
if (e.code === "23505") throw new SlotTakenError(); // overlap
if (e.code === "23503") throw new OutsideAvailabilityError(); // outside availability
throw e;
}
}
One single INSERT. No transaction. No race conditions. Clean error codes.
What changed
| Metric | PG 17 | PG 18 |
|---|---|---|
| Queries per booking | 3 | 1 |
| Explicit transaction | SERIALIZABLE | none |
| Lines in booking handler | ~40 | ~12 |
| Error codes to handle | 23P01 + others | 23505, 23503 |
| Availability rule enforced in | Application | Database |
| Overbook logic duplicated | Yes | No |
What it doesn’t fix
- You still need a range column (
during). We keptstart_timeandend_timefor the rest of the app and added a generated column. It costs a bit more storage. - Temporal foreign keys don’t support
ON DELETE CASCADEorRESTRICT. Deleting an availability window still requires application-level cleanup. - UX messages still need translation.
foreign_key_violationis not the same as “the doctor isn’t working at that time.” - Partial
WHEREpredicates withWITHOUT OVERLAPSshould be tested carefully before going to production.
Bottom line
PostgreSQL 18 didn’t add new power — it added clear syntax.
We moved two business rules out of our application code and into the database where they belong. The booking handler went from a complex transactional function to a simple 12-line insert.
For a rule that used to live in a 50-line migration comment with workarounds and custom error handling, this is a real improvement.