Skip to content

How to Use Range Operators (PostgreSQL)

rangeOverlaps(), rangeContains(), and rangeContainedBy() give you direct, type-safe access to PostgreSQL's range type operators (&&, @>, <@) with all six built-in range constructors. Use this guide whenever you store a period, an interval, or a numeric span as a single column and need to query rows by intersection or containment.

When

Use range operators when your data uses a PostgreSQL range type (daterange, tsrange, tstzrange, int4range, int8range, numrange) and you need to filter rows by:

  • Overlap — two periods share at least one point (e.g. "find bookings that conflict with the requested window").
  • Contains — a stored period covers a target point or sub-range (e.g. "which pricing tier applies to today's date?").
  • Contained by — a stored period falls inside a parent window (e.g. "list all events scheduled within Q1").

These map directly onto the PostgreSQL operators &&, @>, and <@, and they work with PostgreSQL's GiST and SP-GiST range indexes — no application-side period-math required.

Distinctive vs. column comparisons: the helpers exist because comparing two ranges with plain >/< does not capture intersection semantics. [a,b] && [c,d] is true when any point overlaps; you cannot express that with column-level scalar predicates.


API

All three helpers share the same shape. Both the tuple form (returns an expression-shaped PredicateExpressionRef accepted by .where()) and the object form (returns a WhereRangeIntent for the backward-compatible planner path) are supported. Their symbolic operators (&&, @>, <@) remain in the narrow predicate operator set; logical compositions made with op('AND', ...), op('OR', ...), or op('NOT', ...) are WHERE-only and should be passed directly to .where().

rangeOverlaps(column, [lower, upper], rangeType?)

typescript
// doctest: skip — API signature reference
import { rangeOverlaps, type PredicateExpressionRef, type RangeType } from '@dbsp/core';

declare const column: string;
declare const range: readonly [unknown, unknown];
declare const rangeType: RangeType | undefined;

const predicate: PredicateExpressionRef = rangeOverlaps(
  column,    // column name on the table being queried
  range,     // [lower, upper] tuple
  rangeType, // defaults to 'daterange'
);

Compiles to "column" && rangeType($1, $2).

rangeContains(column, [lower, upper], rangeType?)

Compiles to "column" @> rangeType($1, $2). Use when the stored range must enclose the test range.

rangeContainedBy(column, [lower, upper], rangeType?)

Compiles to "column" <@ rangeType($1, $2). Use when the test range must enclose the stored one.


Range types

rangeType controls the PostgreSQL constructor function emitted in SQL. It must match the column's declared type.

rangeTypeSQL functionColumn typeBound type
'daterange' (default)daterange($1, $2)daterangedate
'tsrange'tsrange($1, $2)tsrangetimestamp
'tstzrange'tstzrange($1, $2)tstzrangetimestamptz
'int4range'int4range($1, $2)int4rangeinteger
'int8range'int8range($1, $2)int8rangebigint
'numrange'numrange($1, $2)numrangenumeric

Mismatched types fail at the database, not at compile time — pass rangeType explicitly when your column is anything other than daterange.

Bounds default to [) (lower-inclusive, upper-exclusive)

The 2-argument PostgreSQL range constructors (daterange($1, $2), tsrange($1, $2), etc.) emit half-open ranges. Equal endpoints — e.g. daterange('2024-06-15', '2024-06-15') — produce the empty range, which contains no points. To target a single day, encode it as [d, d+1): daterange('2024-06-15', '2024-06-16'). To cover an inclusive [a, b] window, encode it as [a, b+1).


Examples

Booking conflict detection

A reservation system stores room availability as daterange periods. To find conflicts for a candidate booking, search for any existing booking whose period overlaps the candidate.

typescript
import { schema, createOrm, rangeOverlaps } from '@dbsp/core';
import { createPgsqlCompileOnlyAdapter } from '@dbsp/adapter-pgsql';

const __db = schema({
  bookings: {
    id: 'integer',
    roomId: 'integer',
    period: 'daterange',
  },
} as const);
const __orm = createOrm({ schema: __db, adapter: createPgsqlCompileOnlyAdapter() });

// "Does any booking conflict with 2024-06-10..2024-06-15?"
__orm
  .select('bookings')
  .where(rangeOverlaps('period', ['2024-06-10', '2024-06-15']))
  .dump();
// SQL: SELECT bookings.* FROM bookings WHERE period && daterange($1, $2)
// params: ['2024-06-10', '2024-06-15']

Pricing tier lookup

A pricing table stores tiers as daterange periods. To resolve "what tier applies on a given date", check which stored range contains that date (encode the date as a single-day range).

typescript
import { schema, createOrm, rangeContains } from '@dbsp/core';
import { createPgsqlCompileOnlyAdapter } from '@dbsp/adapter-pgsql';

const __db = schema({
  pricingTiers: {
    id: 'integer',
    name: 'text',
    validity: 'daterange',
  },
} as const);
const __orm = createOrm({ schema: __db, adapter: createPgsqlCompileOnlyAdapter() });

// "Which tier covers 2024-06-15?"
// Encode the single day as a half-open range [2024-06-15, 2024-06-16):
__orm
  .select('pricingTiers')
  .where(rangeContains('validity', ['2024-06-15', '2024-06-16']))
  .dump();
// SQL: SELECT "pricingTiers".* FROM "pricingTiers" WHERE validity @> daterange($1, $2)

Quarterly events

Events are stored as tstzrange periods. To list every event scheduled inside Q1 2024 — i.e. whose stored window is contained by [2024-01-01, 2024-04-01) — use rangeContainedBy and pass an explicit rangeType.

typescript
import { schema, createOrm, rangeContainedBy } from '@dbsp/core';
import { createPgsqlCompileOnlyAdapter } from '@dbsp/adapter-pgsql';

const __db = schema({
  events: {
    id: 'integer',
    name: 'text',
    occursAt: 'tstzrange',
  },
} as const);
const __orm = createOrm({ schema: __db, adapter: createPgsqlCompileOnlyAdapter() });

__orm
  .select('events')
  .where(
    rangeContainedBy(
      'occursAt',
      ['2024-01-01T00:00:00Z', '2024-04-01T00:00:00Z'],
      'tstzrange',
    ),
  )
  .dump();
// SQL: SELECT events.* FROM events WHERE "occursAt" <@ tstzrange($1, $2)

Numeric range — pricing band

The same operators work for numeric ranges. A product catalog might store discount tiers as int4range price bands.

typescript
import { schema, createOrm, rangeOverlaps } from '@dbsp/core';
import { createPgsqlCompileOnlyAdapter } from '@dbsp/adapter-pgsql';

const __db = schema({
  discounts: {
    id: 'integer',
    band: 'int4range',
  },
} as const);
const __orm = createOrm({ schema: __db, adapter: createPgsqlCompileOnlyAdapter() });

// "Which discount bands include any price between 50 and 200?"
__orm
  .select('discounts')
  .where(rangeOverlaps('band', [50, 200], 'int4range'))
  .dump();
// SQL: SELECT discounts.* FROM discounts WHERE band && int4range($1, $2)

DDL — index for performance

Range operators benefit substantially from a GiST index on the range column. The DDL helpers wire this up with the gist index method:

typescript
// doctest: skip — exec-only DDL example; requires a live pg.Pool (the
// compile-only adapter used elsewhere in this guide cannot execute DDL).
import { Pool } from 'pg';
import { schema, createOrm } from '@dbsp/core';
import { createPgsqlAdapter } from '@dbsp/adapter-pgsql';

const db = schema({
  bookings: { id: 'integer', period: 'daterange' },
} as const);
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const orm = createOrm({ schema: db, adapter: createPgsqlAdapter(pool) });

await orm.tables.bookings.indexes.create({
  name: 'bookings_period_gist',
  columns: ['period'],
  method: 'gist',
});

Without a GiST index, range queries fall back to a sequential scan; with one, the planner uses index-only &&/@> lookups. See the DDL Helpers guide for the full index API.


Validation

The tuple form requires exactly two elements; passing a 0-, 1-, or 3+-element array throws synchronously with an actionable message:

typescript
// doctest: skip — illustrative error path
rangeOverlaps('period', ['2024-01-01']);
// Error: range tuple must have exactly 2 elements (got 1); use [lower, upper] or pass a RangeValue object

This catches schema/usage drift at query construction time rather than producing malformed SQL the database rejects later.


  • Expression Primitivesop(), fn(), exprRef() if you need a custom operator that range helpers don't cover (e.g. &< strict-left, -|- adjacent).
  • DDL Helpers — create the GiST index that makes these queries fast.
  • Joins — combine range overlap checks with relation hydration.

Released under the MIT License.