PostgreSQL Cheatsheet

Arrays and Ranges

Use this PostgreSQL reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

Array literals and indexing

Any type gets an array type by appending []. The declared length in int[3] is not enforced, so write plain int[].

CREATE TABLE posts (
  id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  tags   text[]  NOT NULL DEFAULT '{}',
  scores int[]
);

INSERT INTO posts (tags, scores) VALUES
  (ARRAY['sql','postgres'], ARRAY[3,5,4]),
  ('{"multi word",plain}',  '{1,2}');

Two literal forms: the ARRAY[...] constructor and the curly-brace string '{a,b}'. Inside the brace form, quote anything containing a comma, a brace, a space, a backslash, or the word NULL.

Arrays are 1-based, and slices use [lo:hi] with either bound optional.

ExpressionResult on ARRAY[1,2,3]
arr[1]1
arr[9]NULL, never an error
arr[2:3]{2,3}
arr[:2]{1,2}
arr[2:]{2,3}
array_length(arr, 1)3, and NULL for an empty array
cardinality(arr)3, and 0 for an empty array
array_upper(arr, 1) / array_lower(arr, 1)3 / 1

You cannot subscript a constructor directly. Parenthesize it:

SELECT ARRAY[1,2,3][2];      -- ERROR, syntax error at "["
SELECT (ARRAY[1,2,3])[2];    -- 2

Array operators and predicates

OperatorMeaning
x = ANY(arr)x equals some element, the array form of IN
x <> ALL(arr)x differs from every element
arr @> otherContains, as sets
arr <@ otherIs contained by
arr && otherOverlaps, shares any element
a || bConcatenate arrays, or push an element on either end
arr = otherElement-wise equality, order matters
SELECT * FROM posts WHERE 'sql' = ANY(tags);
SELECT * FROM posts WHERE tags @> ARRAY['sql','postgres'];   -- has both
SELECT * FROM posts WHERE tags && ARRAY['sql','rust'];       -- has either
SELECT * FROM posts WHERE NOT ('draft' = ANY(tags));
SELECT ARRAY[1,2] || 3, 0 || ARRAY[1,2];                     -- {1,2,3}, {0,1,2}
SELECT * FROM posts WHERE id = ANY($1::bigint[]);            -- one param, many ids

= ANY($1::bigint[]) is the right way to pass a list from an application. It is a single parameter, so it never explodes into a thousand placeholders and it reuses one plan.

NULL semantics bite. 5 = ANY(ARRAY[1, NULL]) is NULL, not false, so a WHERE NOT (...) around it drops the row. @> also does not match NULL elements, because NULL = NULL is unknown.

Array functions

FunctionReturns
array_agg(x [ORDER BY ...])Rows collapsed into one array
array_agg(DISTINCT x)Deduplicated
unnest(arr)One row per element
unnest(arr) WITH ORDINALITYElement plus its 1-based position
array_position(arr, v)First index of v, or NULL
array_positions(arr, v)All indexes, as an array
array_remove(arr, v)Copy without every v
array_replace(arr, old, new)Copy with substitutions
array_append / array_prepend / array_catThe || forms, spelled out
trim_array(arr, n)Drop the last n elements (13+)
array_to_string(arr, sep) / string_to_array(s, sep)Text round trip
ARRAY(SELECT ...)Subquery straight into an array
-- Rows to array, and back
SELECT customer_id, array_agg(id ORDER BY placed_at) AS order_ids
FROM orders GROUP BY customer_id;

SELECT p.id, t.tag, t.ord
FROM posts p
CROSS JOIN LATERAL unnest(p.tags) WITH ORDINALITY AS t(tag, ord);

-- Preserve incoming order when fetching by an id list
SELECT o.* FROM orders o
JOIN unnest($1::bigint[]) WITH ORDINALITY AS k(id, ord) ON k.id = o.id
ORDER BY k.ord;

Put unnest in a LATERAL join, not the select list, or rows with an empty array vanish. LEFT JOIN LATERAL unnest(...) ON true keeps them.

Indexing arrays

@>, <@, &&, and = ANY(...) are answered by GIN.

CREATE INDEX posts_tags_gin ON posts USING gin (tags);
SELECT * FROM posts WHERE tags @> ARRAY['postgres'];

A btree on an array column only helps whole-array equality and sorting, which is almost never what you want. If you need both a tag search and a per-tag foreign key, a join table is the correct model and an array is the denormalized shortcut.

Range types

A range is one value holding a lower bound, an upper bound, and their inclusivity. Six built-in types plus CREATE TYPE ... AS RANGE for your own.

TypeElementDiscrete?
int4rangeintegerYes
int8rangebigintYes
numrangenumericNo
tsrangetimestampNo
tstzrangetimestamptzNo
daterangedateYes

Bound notation is [ ] for inclusive and ( ) for exclusive. The default is [), inclusive lower and exclusive upper, and that is the shape you want for anything continuous, because two [) ranges that touch do not overlap.

SELECT int4range(1, 10);                             -- [1,10)
SELECT '[1,10]'::int4range;                          -- [1,11) after canonicalization
SELECT numrange(1.5, 2.5, '[]');                     -- [1.5,2.5]
SELECT daterange('2026-01-01', '2026-02-01');        -- [2026-01-01,2026-02-01)
SELECT tstzrange(now(), now() + interval '1 hour');
SELECT tsrange(NULL, '2026-01-01');                  -- (,"2026-01-01 00:00:00")
SELECT '(,)'::int4range;                             -- unbounded both ends

Discrete types are canonicalized to [), so [2026-01-01,2026-01-31] is stored and compared as [2026-01-01,2026-02-01). Continuous types are not, so [1.0,2.0] and [1.0,2.0) stay different values. NULL as a bound means infinity, not unknown.

Range operators and functions

OperatorMeaning
r @> xContains an element
r @> s / r <@ sContains, is contained by, another range
r && sOverlaps
r -|- sAdjacent, they touch with no gap and no overlap
r << s / r >> sStrictly left of, strictly right of
r &< s / r &> sDoes not extend right of, does not extend left of
r * sIntersection
r + sUnion, errors unless they touch or overlap
r - sDifference, errors if it would leave two pieces
FunctionReturns
lower(r) / upper(r)The bounds, NULL if infinite or empty
lower_inc(r) / upper_inc(r)Whether each bound is inclusive
lower_inf(r) / upper_inf(r)Whether each side is unbounded
isempty(r)True for 'empty' and for [1,1)
range_merge(r, s)Smallest range covering both, gaps allowed
SELECT '[1,10)'::int4range @> 5              AS has_five;      -- t
SELECT '[1,5)'::int4range && '[4,9)'::int4range AS overlaps;   -- t
SELECT '[1,5)'::int4range -|- '[5,9)'::int4range AS adjacent;  -- t
SELECT '[1,5)'::int4range * '[4,9)'::int4range  AS shared;     -- [4,5)
SELECT range_merge('[1,3)'::int4range, '[8,9)'::int4range);    -- [1,9)

-- Find rows whose validity window covers now
SELECT * FROM prices WHERE valid_during @> now();

-- Duration of a range
SELECT upper(during) - lower(during) AS length FROM bookings;

Index a range column with GiST for @>, &&, and -|-, or SP-GiST for containment on non-overlapping data.

CREATE INDEX bookings_during_gist ON bookings USING gist (during);

Multiranges and no-overlap constraints

Every range type has a matching multirange (14+), an ordered set of non-contiguous ranges, named int4multirange, tstzmultirange, and so on. Subtraction that would split a range is legal on a multirange, which is what makes them worth using.

SELECT '{[1,3),[8,9)}'::int4multirange;
SELECT int4multirange(int4range(1,3), int4range(8,9));
SELECT '{[1,10)}'::int4multirange - '{[3,4)}'::int4multirange;   -- {[1,3),[4,10)}
SELECT range_agg(during) FROM bookings;                          -- rows to multirange
SELECT unnest('{[1,3),[8,9)}'::int4multirange);                  -- back to rows
SELECT '{[1,3)}'::int4multirange @> 2, '{[1,3)}'::int4multirange && '[2,5)'::int4range;

The canonical use of ranges is an EXCLUDE constraint that makes double booking impossible at the storage layer. It needs btree_gist to mix a scalar equality with a range overlap in one GiST index.

CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE bookings (
  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  room_id bigint    NOT NULL REFERENCES rooms,
  during  tstzrange NOT NULL,
  EXCLUDE USING gist (room_id WITH =, during WITH &&)
);
INSERT INTO bookings (room_id, during)
VALUES (1, '[2026-03-01 09:00+00, 2026-03-01 11:00+00)');   -- ok

INSERT INTO bookings (room_id, during)
VALUES (1, '[2026-03-01 10:00+00, 2026-03-01 12:00+00)');
-- ERROR:  conflicting key value violates exclusion constraint
--         "bookings_room_id_during_excl"   (SQLSTATE 23P01)

INSERT INTO bookings (room_id, during)
VALUES (1, '[2026-03-01 11:00+00, 2026-03-01 12:00+00)');   -- ok, [) means they touch

The error code is 23P01, distinct from a unique violation's 23505. To allow cancelled rows to overlap, add a WHERE clause to the constraint:

ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
  EXCLUDE USING gist (room_id WITH =, during WITH &&)
  WHERE (status <> 'cancelled');

Two start_at and end_at columns cannot express this. The constraint is the reason to store a range.