PostgreSQL Cheatsheet
Queries and Joins
Use this PostgreSQL reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Join types
Given a(id) with rows 1, 2, 3 and b(a_id) with rows 2, 3, 4:
| Join | Keeps | Result of a JOIN b ON a.id = b.a_id |
|---|---|---|
INNER JOIN | Matching pairs only | (2,2) (3,3) |
LEFT JOIN | All of a, NULLs for missing b | (1,NULL) (2,2) (3,3) |
RIGHT JOIN | All of b, NULLs for missing a | (2,2) (3,3) (NULL,4) |
FULL JOIN | All of both | (1,NULL) (2,2) (3,3) (NULL,4) |
CROSS JOIN | Every combination | 3 × 3 = 9 rows |
NATURAL JOIN | Joins on every same-named column | Avoid, a new column silently changes the join |
SELECT c.email, o.total FROM customers c JOIN orders o ON o.customer_id = c.id; -- USING collapses the shared column to one output column SELECT * FROM orders JOIN order_items USING (order_id); -- Anti-join: customers with no orders SELECT c.* FROM customers c LEFT JOIN orders o ON o.customer_id = c.id WHERE o.id IS NULL; -- Same thing, usually clearer and often faster SELECT * FROM customers c WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
USING (col) requires the column to be named identically in both tables and emits it
once. ON is explicit and works for any expression. A LEFT JOIN predicate belongs in
the ON clause, because moving it to WHERE filters away the NULL-extended rows and
silently turns the join back into an inner join.
NOT IN with a subquery that can return NULL yields no rows at all. Use NOT EXISTS.
LATERAL and DISTINCT ON
LATERAL lets a subquery in FROM reference columns from earlier FROM items, which
is how you get a per-row top-N.
-- Three most recent orders per customer SELECT c.email, o.id, o.total, o.placed_at FROM customers c CROSS JOIN LATERAL ( SELECT id, total, placed_at FROM orders WHERE customer_id = c.id ORDER BY placed_at DESC LIMIT 3 ) o;
Use LEFT JOIN LATERAL (...) ON true instead of CROSS JOIN LATERAL when you want
customers with zero orders to survive. A set-returning function in FROM is implicitly
lateral, so FROM orders o, unnest(o.tags) t needs no keyword.
DISTINCT ON is the Postgres-specific greatest-n-per-group shortcut. The leading
ORDER BY columns must match the DISTINCT ON list.
-- Exactly one row per customer, the latest order SELECT DISTINCT ON (customer_id) customer_id, id, total, placed_at FROM orders ORDER BY customer_id, placed_at DESC, id DESC;
Add a tiebreaker (id DESC above) or the winner among equal timestamps is arbitrary.
For one row per group, DISTINCT ON is typically faster than a row_number() window
and much shorter than a correlated subquery.
Grouping and aggregates
WHERE filters rows before grouping, HAVING filters groups after. Every non-aggregate
output column must appear in GROUP BY, except columns functionally dependent on a
grouped primary key (9.1+).
SELECT customer_id, count(*) AS orders, sum(total) AS revenue FROM orders WHERE placed_at >= date_trunc('year', now()) GROUP BY customer_id HAVING sum(total) > 1000 ORDER BY revenue DESC;
FILTER (WHERE ...) (9.4+) applies a predicate to one aggregate, so several
differently-filtered counts come out of one scan. It reads better than
sum(CASE WHEN ... THEN 1 ELSE 0 END) and handles NULL correctly.
SELECT date_trunc('day', placed_at) AS day, count(*) AS all_orders, count(*) FILTER (WHERE status = 'paid') AS paid, count(*) FILTER (WHERE status = 'cancelled') AS cancelled, sum(total) FILTER (WHERE status = 'paid') AS revenue, avg(total) FILTER (WHERE total > 0) AS avg_nonzero FROM orders GROUP BY 1 ORDER BY 1;
| Aggregate | Returns |
|---|---|
count(*) / count(col) | Rows / non-null values |
count(DISTINCT col) | Distinct non-null values, no index shortcut |
sum avg min max | The obvious, all skip NULLs |
array_agg(col ORDER BY x) | Array, ordering allowed inside the call |
string_agg(col, ', ' ORDER BY x) | Delimited text |
jsonb_agg / jsonb_object_agg | JSON array / object |
bool_and / bool_or | Boolean fold |
percentile_cont(0.5) WITHIN GROUP (ORDER BY x) | True median, interpolated |
percentile_disc(0.95) WITHIN GROUP (ORDER BY x) | Actual value at the percentile |
mode() WITHIN GROUP (ORDER BY x) | Most common value |
corr regr_slope stddev_samp var_pop | Statistics |
GROUPING SETS, ROLLUP, CUBE
One pass, several grouping levels. ROLLUP (a, b) is shorthand for the hierarchy
(a,b), (a), (), and CUBE (a, b) for every combination.
SELECT status, date_trunc('month', placed_at) AS month, sum(total) FROM orders GROUP BY ROLLUP (status, month) ORDER BY status NULLS LAST, month NULLS LAST; SELECT status, region, sum(total) FROM orders GROUP BY GROUPING SETS ((status), (region), ());
Subtotal rows carry NULL in the columns they aggregate over, which is ambiguous when the
data itself has NULLs. GROUPING(col) returns 1 for a subtotal row and 0 for real data.
SELECT CASE WHEN GROUPING(status) = 1 THEN 'ALL' ELSE status END AS status, sum(total) FROM orders GROUP BY ROLLUP (status);
Set operations, ordering, pagination
| Operator | Result | Duplicates |
|---|---|---|
UNION | Rows in either | Removed, requires a sort or hash |
UNION ALL | Rows in either | Kept, always cheaper |
INTERSECT | Rows in both | Removed |
EXCEPT | In the first, not the second | Removed |
... ALL variants | Same, multiset semantics | Kept |
Both sides need the same column count and compatible types, and only one ORDER BY is
allowed, at the very end.
SELECT id, 'order' AS kind FROM orders UNION ALL SELECT id, 'refund' FROM refunds ORDER BY id DESC LIMIT 50;
ORDER BY sorts NULLs last for ASC and first for DESC by default. Say what you
mean.
SELECT * FROM orders ORDER BY shipped_at DESC NULLS LAST; SELECT * FROM orders ORDER BY status, placed_at DESC; SELECT * FROM orders ORDER BY random() LIMIT 1; -- fine on small tables only
LIMIT n OFFSET m reads and discards m rows every time, so page 10000 costs 10000
pages of work. Keyset pagination carries the last row's sort key forward and stays
O(page size).
-- Page 1 SELECT id, placed_at FROM orders ORDER BY placed_at DESC, id DESC LIMIT 20; -- Next page, given the last row's (placed_at, id) SELECT id, placed_at FROM orders WHERE (placed_at, id) < ($1, $2) ORDER BY placed_at DESC, id DESC LIMIT 20;
The row-comparison form (a, b) < ($1, $2) is exactly what a btree on
(placed_at DESC, id DESC) can seek to. Keyset pagination cannot jump to an arbitrary
page number, which is the trade.
Conditionals and pattern matching
| Expression | Returns |
|---|---|
coalesce(a, b, c) | First non-null argument |
nullif(a, b) | NULL when a = b, else a |
greatest(a, b) / least(a, b) | Extremes across arguments, ignoring NULLs |
CASE WHEN ... THEN ... ELSE ... END | Branching, first match wins |
CASE x WHEN 1 THEN ... END | Simple form, equality on x |
SELECT id, coalesce(nickname, name, 'anon') AS display, nullif(discount, 0) AS discount, greatest(created_at, updated_at) AS touched, CASE WHEN total >= 1000 THEN 'large' WHEN total >= 100 THEN 'medium' ELSE 'small' END AS bucket FROM orders;
| Operator | Matches |
|---|---|
LIKE 'a%' | % any run, _ one character |
ILIKE 'a%' | Same, case-insensitive |
NOT LIKE / !~~ | Negation |
SIMILAR TO 'a(b|c)%' | SQL-standard regex, a limited dialect, rarely worth it |
~ '^a.*z$' | POSIX regex, case-sensitive |
~* | POSIX regex, case-insensitive |
!~ / !~* | Negated regex |
x BETWEEN a AND b | Inclusive on both ends |
x IN (1,2,3) | Set membership |
x = ANY($1::int[]) | Array membership, the parameter-friendly IN |
SELECT * FROM users WHERE email ILIKE '%@example.com'; SELECT * FROM logs WHERE message ~ '^(WARN|ERROR)\s'; SELECT regexp_replace('a1b2', '[0-9]', '', 'g'); -- ab SELECT regexp_match('v1.2.3', '^v(\d+)\.(\d+)'); -- {1,2} SELECT * FROM regexp_matches('a1b2', '([a-z])(\d)', 'g'); SELECT split_part('a.b.c', '.', 2); -- b SELECT string_to_array('a,b,c', ','); -- {a,b,c}
Escape a literal % or _ in LIKE with a backslash, or set your own with
LIKE 'a\%b' ESCAPE '\'. Prefer = ANY($1::int[]) over IN (...) when the list comes
from application code, because it is a single parameter instead of a variable-length
statement that defeats plan caching.