6 min read

Range Types in Postgres: The Built-in Solution to Overlap Problems

Range types model time windows, numeric ranges, and any "from-to" data. They support overlap queries natively and combine with EXCLUDE constraints to prevent invalid data.

The most common shape I see hand-rolled in application code: "this thing is valid from time X to time Y." Two columns, valid_from and valid_to, with application logic to check overlaps and validity.

Postgres has a native type for this: range types. They store a from-to pair, support overlap queries directly, and combine with exclusion constraints to prevent invalid data at the schema level. Most teams I introduce them to wonder why they did not start with them.

The basics

CREATE TABLE bookings (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  resource_id BIGINT NOT NULL,
  during TSTZRANGE NOT NULL
);

INSERT INTO bookings (resource_id, during)
VALUES (1, tstzrange('2025-01-15 10:00', '2025-01-15 12:00'));

TSTZRANGE is a range over TIMESTAMPTZ. Other built-in ranges:

  • INT4RANGE, INT8RANGE: integer ranges.
  • NUMRANGE: numeric.
  • DATERANGE: dates.
  • TSRANGE: timestamps without timezone.

You can also create custom range types over any orderable type.

Overlap and containment

The key operators:

-- Overlap: do two ranges intersect?
SELECT * FROM bookings
WHERE during && tstzrange('2025-01-15 11:00', '2025-01-15 13:00');

-- Containment: is one range inside another?
SELECT * FROM bookings
WHERE during @> tstzrange('2025-01-15 10:30', '2025-01-15 11:30');

-- Adjacency
SELECT * FROM bookings
WHERE during -|- tstzrange('2025-01-15 12:00', '2025-01-15 14:00');

These are SQL-level — no application-side comparison logic needed.

Indexing for fast overlap queries

For overlap queries to be fast, use a GiST index:

CREATE INDEX bookings_during_gist ON bookings USING GIST (during);

Now WHERE during && '...' uses the index instead of seq-scanning.

Exclusion constraints: enforce no overlap

The killer feature. With the btree_gist extension and an EXCLUDE constraint:

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,
  EXCLUDE USING GIST (resource_id WITH =, during WITH &&)
);

No two bookings of the same resource can have overlapping ranges. Postgres enforces this. An INSERT of an overlapping booking fails with a constraint error.

The alternative — application-level overlap detection with locking — is harder to write correctly and has races. The exclusion constraint has none of that.

Inclusive vs exclusive bounds

Ranges have inclusive or exclusive bounds:

-- '[)' is the default: inclusive lower, exclusive upper
tstzrange('2025-01-15 10:00', '2025-01-15 12:00')
-- This range includes 10:00 but not 12:00

-- Specify explicitly
tstzrange('2025-01-15 10:00', '2025-01-15 12:00', '[]')  -- both inclusive
tstzrange('2025-01-15 10:00', '2025-01-15 12:00', '()')  -- both exclusive

For most time-range data, the [) default is right. Two bookings can be "adjacent" — one ends at 12:00, the next starts at 12:00 — without overlapping.

Unbounded ranges

A range with NULL on one side is unbounded:

-- All time after 2025-01-15
tstzrange('2025-01-15', NULL)

-- All time before 2025-01-15
tstzrange(NULL, '2025-01-15')

Useful for "valid from X onward" or "forever" semantics.

A worked example: scheduling

A team I worked with had a calendar booking system with race-condition bugs. Two users could double-book a meeting room because the application's overlap check was not transactional.

The fix:

CREATE TABLE meeting_room_bookings (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  room_id BIGINT NOT NULL,
  during TSTZRANGE NOT NULL,
  user_id BIGINT NOT NULL,
  EXCLUDE USING GIST (room_id WITH =, during WITH &&)
);

The race conditions disappeared because the database now enforces the rule. Two concurrent INSERTs that would overlap: one succeeds, the other gets a clean constraint error to handle.

The application code dropped from a hundred lines of overlap-checking-with-locks to twenty lines of "INSERT and handle the constraint error."

A worked example: price tiers

Versioned pricing where each price has a validity range:

CREATE TABLE product_prices (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  product_id BIGINT NOT NULL,
  price_cents INTEGER NOT NULL,
  valid_during TSTZRANGE NOT NULL,
  EXCLUDE USING GIST (product_id WITH =, valid_during WITH &&)
);

-- Insert today's price (valid forever until superseded)
INSERT INTO product_prices (product_id, price_cents, valid_during)
VALUES (1, 1000, tstzrange('2025-01-15', NULL));

-- Tomorrow, set a new price
UPDATE product_prices
SET valid_during = tstzrange(lower(valid_during), '2025-01-16')
WHERE product_id = 1 AND upper_inf(valid_during);

INSERT INTO product_prices (product_id, price_cents, valid_during)
VALUES (1, 1100, tstzrange('2025-01-16', NULL));

The EXCLUDE constraint guarantees no overlapping prices. Querying "price at time T":

SELECT price_cents
FROM product_prices
WHERE product_id = 1
  AND valid_during @> $1::timestamptz;

The @> operator (containment) finds the row whose validity range contains the given timestamp.

When ranges are wrong

Ranges are not always the right shape:

  • Discrete events ("this happened at this moment") — just a timestamp.
  • Aggregates over windows ("events in the last hour") — a query, not a range column.
  • Open-ended user-defined intervals ("sometime tomorrow") — these need different modeling.

For data that is naturally a from-to pair where overlaps matter, ranges are excellent. For everything else, simpler types are simpler.

What I tell teams

When reviewing a schema with valid_from and valid_to columns:

  • Convert to a range type. Cleaner. Indexable. Constrainable.
  • Use EXCLUDE constraints to prevent overlap if overlaps would be invalid.
  • Use the GiST index for query performance.

This is rarely a major refactor. It is usually a few lines of schema change and some application updates. The benefits — race-condition-free overlap checks, simpler queries — are immediate.