PostgreSQL Cheatsheet

Window Functions and CTEs

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

The OVER clause

A window function sees a set of rows and still returns one value per row. Nothing is collapsed, which is the whole difference from GROUP BY.

SELECT region, day, amt,
       sum(amt)     OVER (PARTITION BY region)                AS region_total,
       row_number() OVER (PARTITION BY region ORDER BY amt DESC) AS rn,
       amt / sum(amt) OVER (PARTITION BY region)              AS share
FROM sales;

Three parts, all optional. PARTITION BY splits the rows into groups, ORDER BY orders them inside a partition, and the frame clause limits which rows in that ordering the function sees.

- OVER () is one partition of every row, in no defined order. - Window functions run after WHERE, GROUP BY, and HAVING, so you cannot filter on one in WHERE. Wrap the query in a subquery or CTE and filter outside. - You can use an aggregate as a window function, and you can combine GROUP BY with a window that then operates on the grouped rows.

The function table

FunctionReturns
row_number()1, 2, 3, ... with no ties
rank()Ties share a rank, then the sequence jumps (1, 1, 3)
dense_rank()Ties share a rank, no jump (1, 1, 2)
percent_rank()(rank - 1) / (rows - 1), in 0–1
cume_dist()Fraction of rows at or before this one
ntile(n)Bucket number, 1 to n, for quartiles and deciles
lag(x [, off [, dflt]])x from a previous row
lead(x [, off [, dflt]])x from a later row
first_value(x)x from the frame's first row
last_value(x)x from the frame's last row of the frame, see below
nth_value(x, n)x from the frame's nth row
sum avg count min max array_agg string_aggAny aggregate, over the frame

lag and lead return NULL at the edges unless you pass a default.

-- Day-over-day delta and percent change
SELECT day, amt,
       amt - lag(amt) OVER (ORDER BY day)                          AS delta,
       round(100.0 * (amt - lag(amt) OVER (ORDER BY day))
             / nullif(lag(amt) OVER (ORDER BY day), 0), 1)         AS pct
FROM sales ORDER BY day;

Frames and the default-frame trap

The frame clause is {ROWS | RANGE | GROUPS} BETWEEN start AND end, with bounds UNBOUNDED PRECEDING, n PRECEDING, CURRENT ROW, n FOLLOWING, UNBOUNDED FOLLOWING.

Moden counts
ROWSPhysical rows
RANGEValues within n of the current row's ORDER BY value
GROUPSPeer groups, rows tied on the ORDER BY value (11+)

The default frame depends on whether you wrote ORDER BY, and this is the single most common window-function bug.

WindowDefault frameEffect
OVER (PARTITION BY x)Whole partitionsum is the partition total
OVER (PARTITION BY x ORDER BY y)RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWsum is a running total, and last_value is the current row

So last_value(amt) OVER (ORDER BY day) returns the current row's own amt, not the partition's final value. Spell the frame out:

SELECT day, amt,
       last_value(amt) OVER (ORDER BY day)                  AS wrong,   -- current row
       last_value(amt) OVER (ORDER BY day
         ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS right_
FROM sales;

Note also that the default is RANGE, so with ORDER BY day every row sharing a date is inside the frame, and a "running total" over duplicated dates jumps by the whole day's worth at once. Use ROWS when you mean rows.

-- Running total, 7-row moving average, and a value-based window
SELECT day,
  sum(amt) OVER (ORDER BY day ROWS UNBOUNDED PRECEDING)              AS running,
  avg(amt) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma7,
  sum(amt) OVER (ORDER BY day
                 RANGE BETWEEN '7 days' PRECEDING AND CURRENT ROW)   AS last_7d
FROM sales;

ROWS UNBOUNDED PRECEDING is shorthand for BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. EXCLUDE CURRENT ROW, EXCLUDE GROUP, EXCLUDE TIES, and EXCLUDE NO OTHERS (11+) trim the frame further, which is how you get "average of the others".

Named windows and top-N per group

Repeating a long OVER (...) is what the WINDOW clause is for. It sits after HAVING and before ORDER BY.

SELECT region, day, amt,
       row_number() OVER w AS rn,
       rank()       OVER w AS rk,
       sum(amt)     OVER (w ROWS UNBOUNDED PRECEDING) AS running
FROM sales
WINDOW w AS (PARTITION BY region ORDER BY day)
ORDER BY region, day;

Top-N per group needs the filter outside the window, because WHERE runs first.

-- Top 3 orders per customer
SELECT * FROM (
  SELECT o.*, row_number() OVER (PARTITION BY customer_id
                                 ORDER BY total DESC, id) AS rn
  FROM orders o
) t
WHERE rn <= 3;

For exactly one row per group, DISTINCT ON is shorter and usually faster, since it can stop at the first row of each group given the right index.

SELECT DISTINCT ON (customer_id) customer_id, id, total
FROM orders
ORDER BY customer_id, total DESC, id;

Use rank() instead of row_number() when ties should all survive the rn <= 3 cut.

WITH: common table expressions

WITH names a subquery so the main query can read it, and reuse it, by name.

WITH paid AS (
  SELECT * FROM orders WHERE status = 'paid'
), per_customer AS (
  SELECT customer_id, count(*) AS n, sum(total) AS revenue
  FROM paid GROUP BY customer_id
)
SELECT c.email, p.n, p.revenue
FROM per_customer p JOIN customers c ON c.id = p.customer_id
WHERE p.revenue > 1000
ORDER BY p.revenue DESC;

Before 12 a CTE was always an optimization fence, materialized once and never inlined. From 12 the default is: inline it if it is referenced exactly once, is not recursive, and has no side effects. Override either way.

FormBehavior
WITH x AS (...)Inlined when read once and side-effect free (12+)
WITH x AS MATERIALIZED (...)Always computed once into a temp result
WITH x AS NOT MATERIALIZED (...)Always inlined, so predicates push down

Reach for MATERIALIZED when a CTE is expensive and read several times, or when inlining makes the planner recompute a volatile function. Reach for NOT MATERIALIZED when a fence is stopping a WHERE from pushing into the CTE and you see a full scan in EXPLAIN.

WITH RECURSIVE

Two branches joined by UNION ALL: a non-recursive seed, then a term that references the CTE name. Iteration stops when the recursive term returns no rows. UNION instead of UNION ALL deduplicates, which can stop a cycle at the cost of a hash of every row.

-- Walk a tree down from the roots, carrying depth and a sort path
WITH RECURSIVE tree AS (
  SELECT id, parent_id, name, 1 AS depth, ARRAY[id] AS path
  FROM categories
  WHERE parent_id IS NULL
  UNION ALL
  SELECT c.id, c.parent_id, c.name, t.depth + 1, t.path || c.id
  FROM categories c
  JOIN tree t ON c.parent_id = t.id
)
SELECT id, repeat('  ', depth - 1) || name AS label, depth
FROM tree
ORDER BY path;
-- A generated series, and the ancestors of one node
WITH RECURSIVE fib(a, b) AS (
  SELECT 0, 1
  UNION ALL
  SELECT b, a + b FROM fib WHERE b < 1000
)
SELECT a FROM fib;

WITH RECURSIVE up AS (
  SELECT * FROM categories WHERE id = 42
  UNION ALL
  SELECT c.* FROM categories c JOIN up ON up.parent_id = c.id
)
SELECT * FROM up;

Dirty data with a loop in it will spin forever. Guard it with a depth cap, a path array check, or the CYCLE clause (14+):

WITH RECURSIVE tree AS (
  SELECT id, parent_id FROM categories WHERE id = 1
  UNION ALL
  SELECT c.id, c.parent_id FROM categories c JOIN tree t ON c.parent_id = t.id
) CYCLE id SET is_cycle USING cycle_path
SELECT id, is_cycle FROM tree;

SEARCH BREADTH FIRST BY id SET ord and SEARCH DEPTH FIRST BY id SET ord (14+) generate the ordering column for you instead of hand-rolling a path array. The recursive term may reference the CTE only once, and not inside an outer join, a subquery, or an aggregate.

Data-modifying CTEs

INSERT, UPDATE, DELETE, and MERGE can all live in a WITH, as long as they carry RETURNING. Everything runs in one statement and one snapshot.

-- Archive and delete in one statement
WITH moved AS (
  DELETE FROM events
  WHERE occurred_at < now() - interval '90 days'
  RETURNING *
)
INSERT INTO events_archive SELECT * FROM moved;
-- Insert a parent and its children together
WITH new_order AS (
  INSERT INTO orders (customer_id, total) VALUES ($1, $2) RETURNING id
)
INSERT INTO order_items (order_id, sku, qty)
SELECT new_order.id, i.sku, i.qty
FROM new_order, jsonb_to_recordset($3::jsonb) AS i(sku text, qty int)
RETURNING *;

The trap: all sub-statements see the same snapshot, so a data-modifying CTE cannot observe another one's changes. In the archive example the outer INSERT reads moved, the returned rows, and not the table. Two CTEs updating the same row have undefined order. Sub-statement execution order is also not guaranteed beyond the data dependencies you create through RETURNING.