Model temporal availability with ranges and multiranges, understand canonical/empty/unbounded bounds, and enforce non-overlap under concurrency with a GiST-backed exclusion constraint.

Range/Multirange Types, Exclusion Constraints, Temporal Scheduling, and Overlap Models

Model temporal availability with ranges and multiranges, understand canonical/empty/unbounded bounds, and enforce non-overlap under concurrency with a GiST-backed exclusion constraint.

Intermediate → Advanced180–240 minutesAdvanced PostgreSQL data modelingCurrent patched PostgreSQL 18.xCore PostgreSQL; Lesson 5 uses trusted supplied btree_gist (database CREATE privilege or admin-preinstalled)ServiceHub disposable schema: app.ch17_*Owner-equivalent lab role with CREATE in schema appLocal/free tooling; psql recommendedLast reviewed: August 2026

Learning outcomes

ServiceHub schedules technicians in time windows. Storing start_at and end_at as unrelated scalar columns makes overlap logic repetitive and easy to race. PostgreSQL range types make containment, overlap, adjacency, emptiness, and unbounded intervals first-class operations, while exclusion constraints let the database enforce non-overlap under concurrent transactions.

01

Interpret inclusive/exclusive, empty, unbounded, and canonical range values.

02

Use overlap, containment, adjacency, intersection, and multirange operations.

03

Build a GiST range index and a technician+time exclusion constraint.

04

Demonstrate why application-only SELECT-before-INSERT overlap checks race.

05

Explain how the exclusion constraint behaves under concurrent conflicting inserts.

1. Range values encode their boundary semantics

sql · range boundary diagnostics
SELECT  '[2026-08-01,2026-08-31]'::daterange AS input_canonicalized,  lower('[2026-08-01,2026-08-31]'::daterange) AS lower_bound,  upper('[2026-08-01,2026-08-31]'::daterange) AS upper_bound,  lower_inc('[2026-08-01,2026-08-31]'::daterange) AS lower_inclusive,  upper_inc('[2026-08-01,2026-08-31]'::daterange) AS upper_inclusive;

daterange is discrete and canonicalizes equivalent representations to lower-inclusive/upper-exclusive [). Thus an inclusive August 31 upper date displays as September 1 exclusive. Continuous timestamp ranges do not have the same discrete next-value canonicalization.

sql · empty and unbounded ranges
SELECT  isempty('[4,4)'::int4range) AS empty_range,  '(,)'::tstzrange AS all_timestamps,  lower_inf('(,2026-08-20 00:00+00)'::tstzrange) AS no_lower_bound,  upper_inf('[2026-08-20 00:00+00,)'::tstzrange) AS no_upper_bound;

Missing bounds are special unbounded range semantics, not ordinary finite subtype values. The literal empty represents a range containing no points.

2. Overlap and adjacency are different

sql · half-open scheduling semantics
SELECT  tstzrange('2026-08-18 10:00+00','2026-08-18 11:00+00','[)')    && tstzrange('2026-08-18 10:30+00','2026-08-18 11:30+00','[)')       AS overlaps,  tstzrange('2026-08-18 10:00+00','2026-08-18 11:00+00','[)')    -|- tstzrange('2026-08-18 11:00+00','2026-08-18 12:00+00','[)')       AS adjacent_not_overlapping,  tstzrange('2026-08-18 10:00+00','2026-08-18 12:00+00','[)')    @> TIMESTAMPTZ '2026-08-18 11:30+00'       AS contains_time;

Half-open scheduling ranges let one appointment end exactly when the next begins without overlap. The adjacency operator -|- captures that “touching but non-overlapping” relationship.

3. Multiranges represent several disjoint windows

sql · technician availability as a multirange
SELECT tstzmultirange(  tstzrange('2026-08-18 08:00+00','2026-08-18 12:00+00','[)'),  tstzrange('2026-08-18 13:00+00','2026-08-18 17:00+00','[)')) AS working_windows;SELECT tstzmultirange(  tstzrange('2026-08-18 08:00+00','2026-08-18 12:00+00','[)'),  tstzrange('2026-08-18 13:00+00','2026-08-18 17:00+00','[)')) @> TIMESTAMPTZ '2026-08-18 14:00+00' AS available_at_14;

A multirange is an ordered set of non-empty, non-overlapping ranges. Constructors normalize overlaps/adjacent pieces as needed. It is useful for availability windows, but if each interval needs separate identity, reason, owner, or approval state, model them as child rows.

4. Build a range-indexed booking table

sql · single-column range index
DROP TABLE IF EXISTS app.ch17_booking_bad;DROP TABLE IF EXISTS app.ch17_booking;CREATE TABLE app.ch17_booking_bad (  booking_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,  technician_id bigint NOT NULL,  during tstzrange NOT NULL CHECK (NOT isempty(during)));CREATE INDEX ch17_booking_bad_during_gistON app.ch17_booking_bad USING GIST (during);EXPLAIN (COSTS OFF)SELECT *FROM app.ch17_booking_badWHERE during && tstzrange(  '2026-08-18 10:00+00','2026-08-18 11:00+00','[)');

GiST can accelerate range operators including overlap &&, containment, positional comparisons, and adjacency. The index accelerates search; it does not by itself enforce a no-overlap business rule.

5. Wrong approach: application-only precheck races

Consider this common transaction pattern on ch17_booking_bad:

sql · session A: check then pause before insert
BEGIN;SELECT count(*) AS conflictsFROM app.ch17_booking_badWHERE technician_id = 7  AND during && tstzrange(    '2026-08-18 10:00+00','2026-08-18 11:00+00','[)'  );-- returns 0; keep the transaction open
sql · session B: the same check also sees zero and inserts
BEGIN;SELECT count(*) AS conflictsFROM app.ch17_booking_badWHERE technician_id = 7  AND during && tstzrange(    '2026-08-18 10:30+00','2026-08-18 11:30+00','[)'  );-- also returns 0INSERT INTO app.ch17_booking_bad(technician_id,during)VALUES (  7,  tstzrange('2026-08-18 10:30+00','2026-08-18 11:30+00','[)'));COMMIT;
sql · session A inserts after its stale precheck
INSERT INTO app.ch17_booking_bad(technician_id,during)VALUES (  7,  tstzrange('2026-08-18 10:00+00','2026-08-18 11:00+00','[)'));COMMIT;SELECT * FROM app.ch17_booking_badWHERE technician_id = 7ORDER BY booking_id;

Under ordinary MVCC both SELECT checks can truthfully observe no committed conflicting row and both inserts can succeed. A transaction boundary around the application check is not enough to make the invariant atomic.

6. Repair with a GiST-backed exclusion constraint

To combine scalar technician equality with range overlap in one GiST constraint, use the PostgreSQL-supplied btree_gist extension. It is an optional bundled extension, not a third-party service.

On some operating-system packages, supplied contrib modules are installed through a separate postgresql-contrib-style package. If pg_available_extensions does not list btree_gist, install the matching PostgreSQL 18 contrib package rather than substituting an unrelated third-party extension.

sql · install supplied operator classes and create the invariant
SELECT name, default_version, installed_versionFROM pg_available_extensionsWHERE name = 'btree_gist';-- btree_gist is trusted in PostgreSQL 18:-- a non-superuser may install it with CREATE privilege on this database.CREATE EXTENSION IF NOT EXISTS btree_gist;CREATE TABLE app.ch17_booking (  booking_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,  technician_id bigint NOT NULL,  during tstzrange NOT NULL CHECK (NOT isempty(during)),  CONSTRAINT ch17_booking_no_overlap  EXCLUDE USING GIST (    technician_id WITH =,    during WITH &&  ));

The exclusion constraint creates its supporting GiST index. Its rule is: for any two rows, PostgreSQL must not find both the same technician and overlapping ranges simultaneously.

sql · non-overlap and adjacency succeed; overlap fails
INSERT INTO app.ch17_booking(technician_id,during) VALUES(7, tstzrange('2026-08-18 10:00+00','2026-08-18 11:00+00','[)'));-- Adjacent is allowed:INSERT INTO app.ch17_booking(technician_id,during) VALUES(7, tstzrange('2026-08-18 11:00+00','2026-08-18 12:00+00','[)'));-- Different technician can overlap:INSERT INTO app.ch17_booking(technician_id,during) VALUES(8, tstzrange('2026-08-18 10:30+00','2026-08-18 11:30+00','[)'));-- Same technician + overlap is rejected:INSERT INTO app.ch17_booking(technician_id,during) VALUES(7, tstzrange('2026-08-18 10:30+00','2026-08-18 11:30+00','[)'));

7. The database invariant also handles concurrency

Repeat the two-session race against app.ch17_booking. When two concurrent inserts could violate the exclusion rule, the constraint participates in concurrency control: one transaction can wait on the other; after the first commits, the conflicting second insert fails with an exclusion-constraint violation rather than allowing two committed overlaps.

sql · query through the exclusion constraint's GiST index
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)SELECT booking_id, technician_id, duringFROM app.ch17_bookingWHERE technician_id = 7  AND during && tstzrange(    '2026-08-18 10:15+00','2026-08-18 10:45+00','[)'  );

The constraint is stronger than “we usually precheck.” It declares the invariant in the database where every writer—including scripts and future services—must satisfy it.

8. Cleanup and production judgment

sql · cleanup Chapter 17 objects
DROP TABLE IF EXISTS app.ch17_booking CASCADE;DROP TABLE IF EXISTS app.ch17_booking_bad CASCADE;DROP TABLE IF EXISTS app.ch17_article CASCADE;DROP TABLE IF EXISTS app.ch17_work_order_technician CASCADE;DROP TABLE IF EXISTS app.ch17_technician CASCADE;DROP TABLE IF EXISTS app.ch17_work_order_bad CASCADE;DROP TABLE IF EXISTS app.ch17_array_work_order CASCADE;DROP TABLE IF EXISTS app.ch17_profile CASCADE;DROP TABLE IF EXISTS app.ch17_json_lab CASCADE;
Production judgment

Use range types when the domain itself is an interval and overlap/containment are first-class operations. Use an exclusion constraint when non-overlap is a correctness invariant. Application prechecks remain useful for friendly validation messages, but the database constraint is the race-safe authority.

Check your understanding

  1. Why does daterange canonicalize some inclusive upper bounds?
  2. What is the difference between overlap and adjacency?
  3. Why can an application SELECT-before-INSERT check race?
  4. What does btree_gist contribute to the composite exclusion constraint?
  5. What happens to a concurrent conflicting insert when the exclusion constraint is present?
Review the answers

Discrete date ranges have a canonical step and normalize to equivalent [) bounds. Adjacent ranges touch without sharing points. Two MVCC prechecks can both observe no committed conflict before either insert commits. btree_gist supplies GiST equality semantics for the scalar technician_id alongside range overlap. Conflicting concurrent inserts are serialized through the constraint; after one commits, the other cannot also commit the overlap.

Authoritative references

These data types and index/operator contracts are version-sensitive. The lesson uses the PostgreSQL 18 primary documentation below.

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.