GiST Indexes for Range and Exclusion Queries #
A booking system checks “is this room free between these two timestamps?” with WHERE room_id = $1 AND NOT (ends_at <= $2 OR starts_at >= $3). The plan reads every booking for the room and filters. Separately, two users occasionally double-book the same slot, because the check and the insert are not atomic. A range column with a GiST index solves both: the overlap test becomes an index probe, and an exclusion constraint makes double-booking impossible.
GIN and GiST are introduced in specialized index types (GIN/GiST). This page covers the range side of GiST.
The Index Condition #
B-trees order values along one dimension, which is why a two-sided overlap test cannot be a bounded index scan, as discussed in join filter in nested loops without an index. GiST is a framework for indexes over types with no total order: each internal entry holds a bounding predicate covering its subtree, so a search descends only the branches that could contain matches.
For range types — tstzrange, daterange, int4range and friends — the built-in GiST operator class supports:
&&overlaps, the operator behind availability checks;@>and<@containment, includingrange @> element;-|-adjacency,<<and>>strictly left or right of.
Two ways to get a range column: store one directly, or generate it from existing bounds:
ALTER TABLE bookings ADD COLUMN during tstzrange
GENERATED ALWAYS AS (tstzrange(starts_at, ends_at, '[)')) STORED;
CREATE INDEX CONCURRENTLY bookings_during_gist ON bookings USING gist (room_id, during);
Including room_id in the GiST index requires the btree_gist extension, which provides operator classes so scalar columns can participate. That combination is what makes both the query and the constraint efficient.
An exclusion constraint uses the same index to enforce a rule across rows:
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&);
It reads as: no two rows may have equal room_id and overlapping during. PostgreSQL enforces it with the index, atomically, under concurrency.
Annotated EXPLAIN Evidence #
Before, with two comparisons:
EXPLAIN (ANALYZE, BUFFERS)
SELECT 1 FROM bookings
WHERE room_id = 41
AND starts_at < '2026-09-20 12:00+00' AND ends_at > '2026-09-20 10:00+00';
Index Scan using bookings_room_starts_idx on bookings (actual time=0.05..18.4 rows=1 loops=1)
Index Cond: ((room_id = 41) AND (starts_at < '2026-09-20 12:00:00+00'::timestamptz))
Filter: (ends_at > '2026-09-20 10:00:00+00'::timestamptz)
Rows Removed by Filter: 41208
Buffers: shared hit=1204
-- the second bound is a filter: every earlier booking for the room is examined
After, with a range and GiST:
EXPLAIN (ANALYZE, BUFFERS)
SELECT 1 FROM bookings
WHERE room_id = 41 AND during && tstzrange('2026-09-20 10:00+00', '2026-09-20 12:00+00');
Index Scan using bookings_during_gist on bookings (actual time=0.03..0.03 rows=1 loops=1)
Index Cond: ((room_id = 41) AND (during && '["2026-09-20 10:00:00+00","2026-09-20 12:00:00+00")'::tstzrange))
Buffers: shared hit=4
Execution Time: 0.05 ms
-- both dimensions in the index condition; four buffers
And the constraint doing its job:
INSERT INTO bookings (room_id, during) VALUES (41, tstzrange('2026-09-20 11:00+00','2026-09-20 13:00+00'));
ERROR: conflicting key value violates exclusion constraint "bookings_no_overlap"
DETAIL: Key (room_id, during)=(41, ["2026-09-20 11:00:00+00","2026-09-20 13:00:00+00")) conflicts with
existing key (room_id, during)=(41, ["2026-09-20 10:00:00+00","2026-09-20 12:00:00+00")).
Step-by-Step Resolution #
-
Add the range column as a generated column so it cannot drift from the bounds, choosing inclusivity deliberately —
'[)'(inclusive start, exclusive end) is right for time intervals. -
Install
btree_gistif the query also filters on scalar columns such asroom_id. -
Create the GiST index concurrently, with the equality column first:
CREATE INDEX CONCURRENTLY bookings_during_gist ON bookings USING gist (room_id, during); -
Rewrite queries to use range operators —
&&for overlap,@>for containment — so the index condition covers the whole test. -
Add the exclusion constraint once existing data is clean; the build fails on existing overlaps, which is a data-quality finding rather than an obstacle.
-
Handle the constraint violation in the application as a retry or a user-facing conflict message; it replaces a check-then-insert race.
-
Measure index size and build time. GiST indexes are larger and slower to build than B-trees; on very large tables consider partitioning by time so each partition’s index stays manageable.
Before and After #
-- BEFORE: two comparisons on a B-tree
Index Cond: (room_id = 41 AND starts_at < …) Filter: (ends_at > …) Rows Removed: 41208 18.4 ms
-- AFTER: generated range column + GiST index + exclusion constraint
Index Cond: (room_id = 41 AND during && '[…)') Buffers: shared hit=4 0.05 ms
Where GiST is the wrong tool #
GiST is not a general replacement for B-trees. For plain equality and ordered range scans on scalar columns, a B-tree is smaller, faster and supports index-only scans more readily. GiST indexes are lossy for some operator classes, meaning the executor rechecks conditions against the heap — visible as Rows Removed by Index Recheck — and they cost more to build and maintain.
Use GiST when the question is genuinely multi-dimensional or non-orderable: ranges, geometric containment, nearest-neighbour ordering (ORDER BY point <-> :target LIMIT 10, which GiST supports natively), and full-text search where the index must be small rather than fast. For equality-heavy JSON containment queries, GIN is the better fit, compared in when to use GIN over B-tree.
SP-GiST is worth knowing about for non-balanced structures such as quadtrees and radix trees; for range types it can outperform GiST on some workloads. Both are worth benchmarking on real data before committing, because their relative cost depends heavily on how values are distributed.
Common Pitfalls #
Comparing bounds instead of using range operators. Only one bound can be an index condition. Diagnostic signal: a Filter on the second bound. Fix: a range column and &&.
Forgetting btree_gist. Scalar columns cannot join a GiST index without it. Diagnostic signal: error creating the index or the constraint. Fix: create the extension.
Inclusive end bounds. '[]' ranges make adjacent bookings overlap at the boundary. Diagnostic signal: spurious conflicts between back-to-back slots. Fix: use '[)'.
Adding the constraint to dirty data. The build fails on existing overlaps. Diagnostic signal: constraint creation error naming two rows. Fix: find and resolve overlaps first with a self-join on &&.
Frequently Asked Questions #
How do I index a query that checks for overlapping time ranges? #
Store the interval as a range column, index it with GiST — including any scalar filter columns via the btree_gist extension — and write the query with the overlap operator so the whole condition becomes an index condition.
What is an exclusion constraint? #
A constraint enforced by an index that forbids rows whose specified columns relate to each other in a given way, such as equal room and overlapping time range. It provides atomic enforcement that a check-then-insert in the application cannot.
Is GiST slower than B-tree? #
For plain scalar equality and ordered ranges, yes: GiST indexes are larger, build more slowly and can require rechecks. GiST wins where B-trees cannot work at all, such as overlap, containment and nearest-neighbour queries.
Related #
- Specialized Index Types (GIN/GiST) — parent guide: choosing an access method
- When to Use GIN Over B-Tree — sibling: the containment-heavy alternative
- Join Filter in Nested Loops Without an Index — range joins that this index fixes