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:

JoinKeepsResult of a JOIN b ON a.id = b.a_id
INNER JOINMatching pairs only(2,2) (3,3)
LEFT JOINAll of a, NULLs for missing b(1,NULL) (2,2) (3,3)
RIGHT JOINAll of b, NULLs for missing a(2,2) (3,3) (NULL,4)
FULL JOINAll of both(1,NULL) (2,2) (3,3) (NULL,4)
CROSS JOINEvery combination3 × 3 = 9 rows
NATURAL JOINJoins on every same-named columnAvoid, 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;
AggregateReturns
count(*) / count(col)Rows / non-null values
count(DISTINCT col)Distinct non-null values, no index shortcut
sum avg min maxThe 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_aggJSON array / object
bool_and / bool_orBoolean 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_popStatistics

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

OperatorResultDuplicates
UNIONRows in eitherRemoved, requires a sort or hash
UNION ALLRows in eitherKept, always cheaper
INTERSECTRows in bothRemoved
EXCEPTIn the first, not the secondRemoved
... ALL variantsSame, multiset semanticsKept

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

ExpressionReturns
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 ... ENDBranching, first match wins
CASE x WHEN 1 THEN ... ENDSimple 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;
OperatorMatches
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 bInclusive 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.