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.
| Expression | Result 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
| Operator | Meaning |
|---|---|
x = ANY(arr) | x equals some element, the array form of IN |
x <> ALL(arr) | x differs from every element |
arr @> other | Contains, as sets |
arr <@ other | Is contained by |
arr && other | Overlaps, shares any element |
a || b | Concatenate arrays, or push an element on either end |
arr = other | Element-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
| Function | Returns |
|---|---|
array_agg(x [ORDER BY ...]) | Rows collapsed into one array |
array_agg(DISTINCT x) | Deduplicated |
unnest(arr) | One row per element |
unnest(arr) WITH ORDINALITY | Element 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_cat | The || 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.
| Type | Element | Discrete? |
|---|---|---|
int4range | integer | Yes |
int8range | bigint | Yes |
numrange | numeric | No |
tsrange | timestamp | No |
tstzrange | timestamptz | No |
daterange | date | Yes |
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
| Operator | Meaning |
|---|---|
r @> x | Contains an element |
r @> s / r <@ s | Contains, is contained by, another range |
r && s | Overlaps |
r -|- s | Adjacent, they touch with no gap and no overlap |
r << s / r >> s | Strictly left of, strictly right of |
r &< s / r &> s | Does not extend right of, does not extend left of |
r * s | Intersection |
r + s | Union, errors unless they touch or overlap |
r - s | Difference, errors if it would leave two pieces |
| Function | Returns |
|---|---|
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.