PostgreSQL Advanced Querying - Complete Notes
A practical and interview-focused guide to composing complex queries with CTEs, recursion, window functions, DISTINCT ON, LATERAL joins and conditional expressions.
00. The advanced-query mental model
An advanced query is usually not one clever trick. It is a sequence of small relational steps: define the correct rows, enrich them, calculate across related rows, and finally shape the output.
Start with the logical processing model from
SQL Fundamentals. FROM and
JOIN build rows, WHERE filters them, grouping creates summaries, and the
select list shapes the result. The tools in this chapter let a query express additional stages
without moving the work into application loops.
Imagine a workshop. A CTE labels an intermediate workbench. A recursive CTE repeatedly feeds yesterday's output into today's work. A window function looks sideways at related rows without packing them into one row. A LATERAL join lets the right-side workbench use the current item from the left. CASE chooses a value for each row.
| Requirement | Best starting tool | Core idea |
|---|---|---|
| Explain a multi-stage query | WITH |
Name each result set |
| Walk a hierarchy or graph | WITH RECURSIVE |
Repeat until no new rows appear |
| Rank or compare related rows | Window function | Keep every detail row |
| Pick one row per group | DISTINCT ON |
Sort, then keep each group's first row |
| Run a correlated row-producing query | LATERAL |
The right side can see the left row |
| Choose a value conditionally | CASE |
SQL's expression-level if/else |
Examples reuse the shop schema from SQL Fundamentals:
customers(customer_id, email, full_name, city, active, created_at)
orders(order_id, customer_id, status, ordered_at)
order_items(order_id, product_id, quantity, unit_price)
products(product_id, name, category, price, stock)
Write the query in inspectable stages. Verify row counts, keys and nullability after each stage. Only then simplify it. A short query that silently multiplies rows or chooses arbitrary ties is not advanced.
01. Common table expressions (CTEs)
A common table expression is a named result available to one statement. It can make a long query read from top to bottom like a data pipeline.
WITH paid_order_totals (order_id, customer_id, ordered_at, total) AS (
SELECT
o.order_id,
o.customer_id,
o.ordered_at,
SUM(oi.quantity * oi.unit_price)
FROM orders AS o
JOIN order_items AS oi USING (order_id)
WHERE o.status = 'paid'
GROUP BY o.order_id, o.customer_id, o.ordered_at
)
SELECT customer_id, COUNT(*) AS paid_orders, SUM(total) AS lifetime_value
FROM paid_order_totals
GROUP BY customer_id
ORDER BY lifetime_value DESC, customer_id;
The name paid_order_totals exists only inside this statement. The optional column
list defines a stable interface for the CTE. Without it, output names come from the inner query.
Multiple CTEs are comma-separated, and a later CTE can read an earlier one.
WITH order_totals AS (
SELECT o.order_id, o.customer_id,
SUM(oi.quantity * oi.unit_price) AS total
FROM orders AS o
JOIN order_items AS oi USING (order_id)
WHERE o.status IN ('paid', 'shipped')
GROUP BY o.order_id, o.customer_id
),
customer_totals AS (
SELECT customer_id, COUNT(*) AS order_count, SUM(total) AS spent
FROM order_totals
GROUP BY customer_id
),
qualified AS (
SELECT *
FROM customer_totals
WHERE order_count >= 3 AND spent >= 10000
)
SELECT c.customer_id, c.full_name, q.order_count, q.spent
FROM qualified AS q
JOIN customers AS c USING (customer_id)
ORDER BY q.spent DESC, c.customer_id;
Use a CTE to name a statement-local stage, a derived table for a small one-use inline stage, and a view for a reusable database interface. These choices mainly express scope and intent. Do not assume a CTE automatically makes a query faster.
Inlining, materialization and optimizer behavior
PostgreSQL can fold a non-recursive, side-effect-free CTE into its parent query. Folding lets the optimizer push parent filters into the CTE and choose one plan across both levels. By default, a qualifying CTE referenced once is normally folded. A CTE referenced more than once is normally materialized: PostgreSQL calculates it once and stores the intermediate result for reuse.
-- Force a separately calculated intermediate result.
WITH expensive AS MATERIALIZED (
SELECT product_id, category, expensive_score(product_id) AS score
FROM products
)
SELECT * FROM expensive WHERE category = 'books';
-- Invite parent predicates to reach the underlying table.
WITH products_stage AS NOT MATERIALIZED (
SELECT * FROM products
)
SELECT *
FROM products_stage
WHERE product_id = 42;
| Choice | Potential benefit | Potential cost |
|---|---|---|
Folded / NOT MATERIALIZED |
Predicate pushdown and index use | Repeated references can repeat work |
MATERIALIZED |
Evaluate once and preserve a boundary | May compute and store unnecessary rows |
Treat these keywords as measured tuning tools. First confirm semantics, inspect the plan with
EXPLAIN (ANALYZE, BUFFERS), and then compare alternatives with representative data.
Volatile functions and data-modifying CTEs have additional semantic reasons not to inline.
Data-modifying CTEs
A top-level WITH can contain INSERT, UPDATE or
DELETE. Its RETURNING result, not the target table, becomes the CTE's
rows. This supports atomic move-and-log patterns.
WITH removed AS (
DELETE FROM orders
WHERE status = 'cancelled'
AND ordered_at < CURRENT_DATE - INTERVAL '1 year'
RETURNING *
)
INSERT INTO archived_orders
SELECT * FROM removed;
Data-modifying CTEs and the main statement share one snapshot and execute concurrently in an
unpredictable order. Communicate through RETURNING, not by expecting one sibling to
see another sibling's table changes. Do not try to modify the same row twice in one statement;
which modification wins is unpredictable.
02. Recursive CTEs
A recursive CTE grows a result from starting rows. It is the standard SQL tool for trees, graph reachability, dependency chains, ancestor paths and generated sequences.
WITH RECURSIVE cte_name (columns...) AS (
anchor_query
UNION ALL
recursive_query_that_references_cte_name
)
SELECT * FROM cte_name;
- The anchor produces the initial working rows.
- The recursive term runs using the current working rows.
- New rows become the next working set and are also added to the result.
- Execution stops when an iteration produces no rows.
PostgreSQL implements this process iteratively even though SQL calls it recursive. A termination
condition is essential. UNION ALL preserves all generated rows and is usually faster.
Plain UNION removes duplicates at each stage and can sometimes stop a cycle, but it
can also hide legitimate duplicate paths and does not solve every graph cycle.
WITH RECURSIVE numbers(n) AS (
VALUES (1)
UNION ALL
SELECT n + 1
FROM numbers
WHERE n < 10
)
SELECT n FROM numbers ORDER BY n;
Walking a hierarchy
Assume a separate employee hierarchy for the next examples:
CREATE TABLE employees (
employee_id bigint PRIMARY KEY,
manager_id bigint REFERENCES employees(employee_id),
full_name text NOT NULL
);
WITH RECURSIVE organization AS (
SELECT
employee_id,
manager_id,
full_name,
0 AS depth,
ARRAY[employee_id] AS path
FROM employees
WHERE employee_id = 10
UNION ALL
SELECT
e.employee_id,
e.manager_id,
e.full_name,
o.depth + 1,
o.path || e.employee_id
FROM employees AS e
JOIN organization AS o ON e.manager_id = o.employee_id
WHERE NOT e.employee_id = ANY(o.path)
)
SELECT employee_id, manager_id, full_name, depth, path
FROM organization
ORDER BY path;
The anchor chooses manager 10. The recursive term finds direct reports of the previous level. The path array serves two purposes: ordering by it displays a depth-first traversal, and rejecting an id already in the path prevents a bad cycle from looping forever. A depth limit can provide an additional guard when business rules define a maximum hierarchy depth.
SEARCH and CYCLE
PostgreSQL supports SQL-standard clauses that generate ordering and cycle-tracking columns. The
traversal's internal visitation order is not a presentation guarantee; use the generated column in
the outer ORDER BY.
WITH RECURSIVE organization(employee_id, manager_id, full_name) AS (
SELECT employee_id, manager_id, full_name
FROM employees
WHERE employee_id = 10
UNION ALL
SELECT e.employee_id, e.manager_id, e.full_name
FROM employees AS e
JOIN organization AS o ON e.manager_id = o.employee_id
)
SEARCH DEPTH FIRST BY employee_id SET traversal_order
CYCLE employee_id SET is_cycle USING traversal_path
SELECT employee_id, manager_id, full_name, is_cycle, traversal_path
FROM organization
WHERE NOT is_cycle
ORDER BY traversal_order;
A small outer LIMIT can help test a possibly looping query, but sorting, joining or
another database implementation may demand the complete recursive result first. Encode a real
stopping rule and explicit cycle detection.
03. Window functions
A window function calculates across rows related to the current row while keeping every input row.
This is the key difference from GROUP BY, which combines a group into one row.
function_or_aggregate(arguments) OVER (
PARTITION BY grouping_expressions
ORDER BY sequencing_expressions
ROWS BETWEEN frame_start AND frame_end
)
| Concept | Question it answers | Scope |
|---|---|---|
| Partition | Which rows belong to the same independent group? | All rows sharing partition values |
| Window order | In what sequence is the calculation defined? | Order inside each partition |
| Frame | Which nearby rows apply for this current row? | A changing subset of the partition |
Omitting PARTITION BY creates one partition for the entire input. Window
ORDER BY controls the calculation but does not guarantee final display order, so use
a separate outer ORDER BY. The rows visible to windows are the virtual table
remaining after FROM, WHERE, GROUP BY and
HAVING.
ROW_NUMBER, RANK and DENSE_RANK
SELECT
o.customer_id,
o.order_id,
o.ordered_at,
ROW_NUMBER() OVER (
PARTITION BY o.customer_id
ORDER BY o.ordered_at DESC, o.order_id DESC
) AS row_number,
RANK() OVER (
PARTITION BY o.customer_id
ORDER BY o.ordered_at DESC
) AS rank,
DENSE_RANK() OVER (
PARTITION BY o.customer_id
ORDER BY o.ordered_at DESC
) AS dense_rank
FROM orders AS o;
| Function | Tie behavior | Example scores 100, 100, 90 |
|---|---|---|
ROW_NUMBER() |
Always assigns different sequential numbers | 1, 2, 3 |
RANK() |
Peers share rank; later ranks have gaps | 1, 1, 3 |
DENSE_RANK() |
Peers share rank; no gaps | 1, 1, 2 |
Rows tied on all window ordering expressions are peers. If the requirement is exactly one stable
row, add a unique tie-breaker such as order_id. If the requirement includes all rows
tied for third place, use RANK() <= 3 or
DENSE_RANK() <= 3 according to the desired gap semantics. PostgreSQL also supplies
PERCENT_RANK, CUME_DIST and NTILE(n) for relative position
and buckets.
Top N per group and window filtering
Window functions logically run after WHERE, and PostgreSQL permits window calls only
in the select list and ORDER BY. Calculate first in a subquery or CTE, then filter at
the outer level.
WITH ranked_orders AS (
SELECT
o.*,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY ordered_at DESC, order_id DESC
) AS position
FROM orders AS o
)
SELECT *
FROM ranked_orders
WHERE position <= 3
ORDER BY customer_id, position;
LAG and LEAD
LAG(value, offset, default) reads an earlier row in window order;
LEAD reads a later row. The offset defaults to 1 and the missing-row result defaults
to null. They avoid a self-join for previous/next comparisons.
SELECT
customer_id,
order_id,
ordered_at,
LAG(ordered_at) OVER (
PARTITION BY customer_id
ORDER BY ordered_at, order_id
) AS previous_order_at,
ordered_at - LAG(ordered_at) OVER (
PARTITION BY customer_id
ORDER BY ordered_at, order_id
) AS time_since_previous
FROM orders
ORDER BY customer_id, ordered_at, order_id;
Window frames: ROWS, RANGE and GROUPS
Ranking and offset functions mainly use the partition and ordering. Aggregate windows and
FIRST_VALUE, LAST_VALUE, and NTH_VALUE are frame-sensitive.
With window ordering, the default is effectively
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, where current row includes its
ordering peers. Duplicate ordering values can therefore move together.
| Mode | An offset counts | Typical use |
|---|---|---|
ROWS |
Physical rows in window order | Last 7 observations |
RANGE |
Distance in the ordering value | Previous 7 calendar days |
GROUPS |
Peer groups with equal ordering values | Previous 3 distinct price levels |
WITH order_totals AS (
SELECT o.customer_id, o.order_id, o.ordered_at,
SUM(oi.quantity * oi.unit_price) AS total
FROM orders AS o
JOIN order_items AS oi USING (order_id)
GROUP BY o.customer_id, o.order_id, o.ordered_at
)
SELECT
customer_id,
order_id,
ordered_at,
total,
SUM(total) OVER (
PARTITION BY customer_id
ORDER BY ordered_at, order_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_spend
FROM order_totals
ORDER BY customer_id, ordered_at, order_id;
-- Current order and previous six orders, even if months apart.
AVG(total) OVER (
PARTITION BY customer_id
ORDER BY ordered_at, order_id
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)
-- All orders in the previous seven calendar days.
SUM(total) OVER (
PARTITION BY customer_id
ORDER BY ordered_at
RANGE BETWEEN INTERVAL '7 days' PRECEDING AND CURRENT ROW
)
Bounds include UNBOUNDED PRECEDING, n PRECEDING,
CURRENT ROW, n FOLLOWING and UNBOUNDED FOLLOWING in valid
order. PostgreSQL can also exclude the current row, its peer group, or peer ties with an
EXCLUDE clause. Use that when a calculation needs neighbors but must omit the current
observation.
With the default ordered frame, LAST_VALUE(x) usually returns the current row or
its last peer, not the partition's final row. For the final value of the full partition, specify
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING. The same frame awareness
matters for FIRST_VALUE and NTH_VALUE.
Grouped aggregate versus aggregate window
-- Collapses orders into one row per customer.
SELECT customer_id, COUNT(*) AS order_count
FROM orders
GROUP BY customer_id;
-- Keeps each order and repeats its customer's count for context.
SELECT order_id, customer_id,
COUNT(*) OVER (PARTITION BY customer_id) AS customer_order_count
FROM orders;
Ordinary aggregates execute before windows. Therefore a window can process grouped results. For
example, after producing one row per day with SUM(total), an outer window can
calculate a running total across those daily rows.
Named windows
SELECT
customer_id,
order_id,
ordered_at,
LAG(ordered_at) OVER customer_timeline AS previous_order_at,
ROW_NUMBER() OVER customer_timeline AS sequence_number
FROM orders
WINDOW customer_timeline AS (
PARTITION BY customer_id
ORDER BY ordered_at, order_id
)
ORDER BY customer_id, ordered_at, order_id;
A named window prevents subtle differences across repeated OVER clauses. Multiple
window functions with compatible definitions may also share sorting work, but verify actual plans
rather than promising it.
04. PostgreSQL DISTINCT ON
DISTINCT ON (expressions) keeps the first row from each equal group after sorting. It
is a concise PostgreSQL-specific solution for top one per group.
SELECT DISTINCT ON (customer_id)
customer_id,
order_id,
status,
ordered_at
FROM orders
ORDER BY customer_id, ordered_at DESC, order_id DESC;
The DISTINCT ON expressions must match the leftmost
ORDER BY expressions. Later ordering expressions decide which row wins inside each
group. Here order_id DESC deterministically resolves identical timestamps. Without
suitable ordering, the chosen row is unpredictable.
| Technique | Strength | Use when |
|---|---|---|
DISTINCT ON |
Compact PostgreSQL syntax | Exactly one first row per group |
ROW_NUMBER |
Portable pattern and exposes position | Top N, or later ranking logic |
RANK |
Preserves ties | All rows tied at a boundary must remain |
If final presentation must be ordered by newest order globally rather than by customer, wrap the
DISTINCT ON query and apply a new outer ORDER BY ordered_at DESC.
05. LATERAL joins
A LATERAL item in FROM can reference columns from earlier items in the
same FROM list. Conceptually, PostgreSQL evaluates it for each left-side row.
SELECT
c.customer_id,
c.full_name,
recent.order_id,
recent.status,
recent.ordered_at
FROM customers AS c
LEFT JOIN LATERAL (
SELECT o.order_id, o.status, o.ordered_at
FROM orders AS o
WHERE o.customer_id = c.customer_id
ORDER BY o.ordered_at DESC, o.order_id DESC
LIMIT 2
) AS recent ON true
ORDER BY c.customer_id, recent.ordered_at DESC, recent.order_id DESC;
The subquery can use c.customer_id because it is lateral.
LEFT JOIN LATERAL preserves customers with no orders, filling
recent columns with null. ON true means correlation is already handled
inside the subquery. Use CROSS JOIN LATERAL when left rows with no right result
should disappear.
SELECT p.product_id, tag
FROM product_documents AS p
CROSS JOIN LATERAL jsonb_array_elements_text(p.document -> 'tags') AS tag;
Table functions in FROM can reference preceding items even when the word
LATERAL is omitted, but writing it can clarify intent. LATERAL is especially useful
for top N per parent, set-returning functions, and calculations that produce multiple columns.
The per-left-row model can be efficient when each correlated lookup uses an index, such as an
index on orders(customer_id, ordered_at DESC, order_id DESC). It can be expensive
for a large left input if every lookup scans many rows. If a normal set-based join states the
same requirement clearly, prefer the simpler form and compare plans with real data.
06. CASE and conditional expressions
CASE is an expression, not a control-flow statement. It returns one value, so it can
appear anywhere a compatible value is allowed: SELECT, WHERE, ORDER BY, aggregates and updates.
Searched and simple CASE
-- Searched CASE: each WHEN is an independent condition.
CASE
WHEN total >= 10000 THEN 'platinum'
WHEN total >= 5000 THEN 'gold'
WHEN total >= 1000 THEN 'silver'
ELSE 'standard'
END
-- Simple CASE: compare one expression for equality.
CASE status
WHEN 'paid' THEN 'payment complete'
WHEN 'shipped' THEN 'on the way'
WHEN 'cancelled' THEN 'closed'
ELSE 'open'
END
PostgreSQL checks WHEN arms from top to bottom and returns the first matching result.
Put narrow or high-priority conditions before broad ones. If no arm matches and ELSE
is absent, the result is null. All possible result arms must resolve to a compatible common type.
CASE generally avoids unnecessary result arms, but it is not a universal shield against every
error. A constant expression such as 1 / 0 may fail during planning even inside an
unreachable arm, and aggregate expressions are calculated before select-list CASE logic.
Conditional aggregation
SELECT
customer_id,
COUNT(*) AS all_orders,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled_orders,
MAX(ordered_at) FILTER (WHERE status = 'shipped') AS last_shipped_at
FROM orders
GROUP BY customer_id;
The CASE pattern is widely portable. PostgreSQL's aggregate FILTER syntax is often
clearer because it separates the aggregate from its input condition. Remember that
SUM(CASE WHEN condition THEN 1 END) returns null when nothing matches; adding
ELSE 0 returns zero. COUNT(CASE WHEN condition THEN 1 END) works because
COUNT(expression) ignores nulls.
Custom sorting
SELECT order_id, status, ordered_at
FROM orders
ORDER BY
CASE status
WHEN 'pending' THEN 1
WHEN 'paid' THEN 2
WHEN 'shipped' THEN 3
WHEN 'cancelled' THEN 4
ELSE 5
END,
ordered_at,
order_id;
COALESCE, NULLIF, GREATEST and LEAST
| Expression | Meaning | Useful example |
|---|---|---|
COALESCE(a, b, c) |
First non-null argument | Display fallback |
NULLIF(a, b) |
Null when values equal, otherwise a | Avoid zero divisor |
GREATEST(a, b) |
Largest argument | Clamp a lower bound |
LEAST(a, b) |
Smallest argument | Clamp an upper bound |
SELECT
COALESCE(city, 'Unknown') AS display_city,
revenue / NULLIF(order_count, 0) AS average_order_value,
GREATEST(stock, 0) AS nonnegative_stock,
LEAST(discount_percent, 100) AS capped_discount
FROM customer_metrics;
Arguments must resolve to suitable common or comparable types. PostgreSQL ignores null arguments
in GREATEST and LEAST, returning null only when all arguments are null.
That null behavior differs from some databases, so do not assume it is portable.
07. Composing the tools
Real reports often combine these features. Keep each stage responsible for one grain and name that grain explicitly.
WITH order_totals AS (
SELECT
o.order_id,
o.customer_id,
o.ordered_at,
SUM(oi.quantity * oi.unit_price) AS total
FROM orders AS o
JOIN order_items AS oi USING (order_id)
WHERE o.status IN ('paid', 'shipped')
GROUP BY o.order_id, o.customer_id, o.ordered_at
),
order_analysis AS (
SELECT
order_totals.*,
LAG(total) OVER customer_orders AS previous_total,
SUM(total) OVER (
PARTITION BY customer_id
ORDER BY ordered_at, order_id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_spend,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY ordered_at DESC, order_id DESC
) AS recency
FROM order_totals
WINDOW customer_orders AS (
PARTITION BY customer_id
ORDER BY ordered_at, order_id
)
)
SELECT
c.customer_id,
c.full_name,
a.order_id AS latest_order_id,
a.total AS latest_total,
a.total - a.previous_total AS change_from_previous,
a.running_spend,
CASE
WHEN a.running_spend >= 10000 THEN 'high value'
WHEN a.running_spend >= 5000 THEN 'growing'
ELSE 'new'
END AS segment
FROM order_analysis AS a
JOIN customers AS c USING (customer_id)
WHERE a.recency = 1
ORDER BY a.running_spend DESC, c.customer_id;
Check the grain at every boundary: order_totals has one row per order;
order_analysis still has one row per order; the final filter keeps one row per
customer. Stating grain makes accidental multiplication visible before it becomes a wrong total.
08. Common mistakes and debugging
| Symptom | Likely cause | Correction |
|---|---|---|
| CTE query became slower | Unwanted materialization or repeated work | Inspect plan; test default, MATERIALIZED and NOT MATERIALIZED |
| Recursive query never ends | No termination or cycle guard | Add business bound and path/CYCLE detection |
| Top row changes between runs | Ties lack a unique ordering key | Add a deterministic tie-breaker |
| Running sum jumps across duplicate dates | Default RANGE frame includes peers | Specify ROWS and a deterministic order |
| LAST_VALUE equals current value | Default frame ends at current peer | Use UNBOUNDED FOLLOWING for full partition |
| Window function rejected in WHERE | Window executes after WHERE | Calculate in subquery/CTE, filter outside |
| DISTINCT ON chooses wrong row | Incorrect or incomplete ORDER BY | Group keys first, winner keys next |
| Customers without orders disappear | CROSS/inner LATERAL behavior | Use LEFT JOIN LATERAL ... ON true |
| CASE result has a type error | Arms lack a common type | Use compatible values or explicit casts |
- Run each CTE body alone and inspect sample rows plus
COUNT(*). - Write down the expected key and grain for each stage.
- Test empty input, nulls, duplicates, tied timestamps and customers with no children.
- Add explicit ordering whenever first, latest, previous or running has meaning.
- Use
EXPLAIN (ANALYZE, BUFFERS)only after correctness is established. - Compare plans and timing using production-shaped volumes and distributions.
09. Practice problems
1. Return each customer's latest order, including customers with no orders.
Start from customers and use LEFT JOIN LATERAL. Inside, correlate on customer id,
order by timestamp and order id descending, and limit to one.
2. Return the two highest-value orders per customer, preserving ties.
First aggregate items to one row per order. Apply RANK() partitioned by customer
and ordered by total descending. Filter rank at most 2 outside the window stage. Decide whether
gaps or dense positions match the phrase "two highest values."
3. Display every employee below a manager and reject hierarchy cycles.
Use the manager as the recursive anchor, join employees on manager id in the recursive term, and
track visited ids in a path array or use PostgreSQL's CYCLE clause.
4. Calculate the difference between each order total and the previous order total.
Build one row per order, then subtract LAG(total) over customer partitions ordered
by timestamp plus order id. The first order has no predecessor, so its difference is null unless
a business-approved default is supplied.
5. Produce paid, shipped and cancelled counts per customer in one row.
Group by customer and use one aggregate with FILTER (WHERE ...) for each status.
Conditional CASE aggregates are an alternative.
10. Interview Q&A
Q: Does a CTE always materialize in PostgreSQL?
No. PostgreSQL can fold a side-effect-free non-recursive CTE into its parent, commonly when it is referenced once. Multiple references commonly favor materialization. MATERIALIZED and NOT MATERIALIZED can control the choice where permitted, with a tradeoff between reuse and optimization across the boundary.
Q: What are the two parts of a recursive CTE?
The anchor creates initial rows. The recursive term references the CTE and creates the next working rows. Iteration ends when no new rows appear. Production queries need an intentional termination rule and cycle handling where cycles are possible.
Q: GROUP BY versus window function?
GROUP BY collapses input rows into one row per group. A window function preserves each row and adds a calculation based on its partition, ordering and possibly frame.
Q: ROW_NUMBER versus RANK versus DENSE_RANK?
ROW_NUMBER always assigns unique consecutive numbers. RANK gives peers the same position and leaves gaps. DENSE_RANK gives peers the same position without gaps. Add a unique ordering key when a single deterministic winner is required.
Q: What are partition and frame?
A partition is the complete independent group available to a window calculation. A frame is a row-dependent subset within that partition. Aggregate windows and first/last/nth value are frame-sensitive.
Q: Why can LAST_VALUE surprise you?
With ordered windows, the default frame ends at the current row's last peer, so LAST_VALUE does not normally see the partition's final row. Use a frame ending at UNBOUNDED FOLLOWING when that is the requirement.
Q: How do you filter a window result?
Calculate it in a subquery or CTE and filter in the outer query. Window functions execute after WHERE and are not permitted directly in that clause.
Q: What does DISTINCT ON do?
It is a PostgreSQL extension that keeps the first sorted row for each distinct expression set. Those expressions must match the leftmost ORDER BY expressions, and later keys choose a deterministic winner.
Q: What problem does LATERAL solve?
It lets a FROM item reference columns from items to its left. This is useful for correlated row-producing calculations such as top N children per parent. LEFT JOIN LATERAL preserves a parent when the lateral item returns no row.
Q: CASE versus aggregate FILTER?
CASE conditionally produces a value and works in many expressions. FILTER decides which rows enter one aggregate and is often clearer for PostgreSQL conditional aggregation. CASE is more portable and supports value transformation beyond aggregate input filtering.
11. One-screen cheat sheet
- CTE: a statement-local named result; use it to expose pipeline stages.
- Optimization: a CTE is not automatically a performance improvement.
- Recursive CTE: anchor + UNION ALL + recursive term + termination.
- Cycles: track a visited path or use CYCLE; do not trust outer LIMIT.
- Window: preserve detail rows while calculating across related rows.
- Partition: independent group; frame: current row's subset.
- Ranking: ROW_NUMBER separates ties, RANK gaps, DENSE_RANK does not.
- Determinism: latest and first need a unique tie-breaker.
- Running total: use explicit ROWS from unbounded preceding to current row.
- Filter window: calculate inside, filter outside.
- DISTINCT ON: group expressions must lead ORDER BY.
- LATERAL: the right FROM item can use the current left row.
- CASE: first matching arm wins; compatible result types are required.
- Null tools: COALESCE defaults; NULLIF can turn a zero divisor into null.