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:

Reference relationships
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)
Correctness before compactness

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.

Basic CTE and explicit output columns
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.

Chaining stages
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;
CTE, derived table or view?

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.

Control the optimization boundary
-- 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.

Archive and delete in one statement
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;
Sibling write order is not a sequencing mechanism

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.

The required shape
WITH RECURSIVE cte_name (columns...) AS (
    anchor_query
  UNION ALL
    recursive_query_that_references_cte_name
)
SELECT * FROM cte_name;
  1. The anchor produces the initial working rows.
  2. The recursive term runs using the current working rows.
  3. New rows become the next working set and are also added to the result.
  4. 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.

Generate a bounded numeric sequence
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:

Hierarchy table
CREATE TABLE employees (
    employee_id bigint PRIMARY KEY,
    manager_id  bigint REFERENCES employees(employee_id),
    full_name   text NOT NULL
);
All reports below one manager, including depth and path
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.

Depth-first display and declarative cycle detection
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;
Never rely on an outer LIMIT as production cycle protection

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.

Anatomy of OVER
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

Rank orders within each customer
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.

Latest three orders per customer
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.

Time since the customer's previous order
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
Running total with deterministic row semantics
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;
Rolling observations versus rolling time
-- 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.

The LAST_VALUE trap

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

One row per customer versus one row per order
-- 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

Reuse one window definition
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.

Latest order per customer
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.

Latest two orders for every customer
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.

Expand values returned by a set-returning function
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.

Performance reasoning

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

Two forms
-- 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.

Short-circuiting has planning caveats

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

Several metrics in one grouped scan
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

Business priority instead of alphabetic order
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
Common null-safe calculations
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.

Customer report with totals, previous value, rank and segment
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
  1. Run each CTE body alone and inspect sample rows plus COUNT(*).
  2. Write down the expected key and grain for each stage.
  3. Test empty input, nulls, duplicates, tied timestamps and customers with no children.
  4. Add explicit ordering whenever first, latest, previous or running has meaning.
  5. Use EXPLAIN (ANALYZE, BUFFERS) only after correctness is established.
  6. 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.

12. Official reference map

Last reviewed · July 2026 · part of knowledge-base