SQL Performance Tuning — Applied Composite Indexes, Pre-Join

ADVComposite IndexesKeyset PaginationPre-Join AggregationRecursive CTETop-N per GroupPostgreSQL-compatible5 questions
1 / 5 · In progress 0 / 5 completed
QUESTION 1

Composite Index Column Order — The “equality first, range later” and leftmost-prefix principles

Composite INDEXLeftmost PrefixEquality → RangeIndex Range Scan
Background

Composite indexes are effective for queries that filter on multiple columns, but performance depends on the column order. Because a B-tree is sorted from the leftmost column onward, putting equality-condition columns (=) first and range-condition columns (>= / <) later keeps the search in one contiguous range. In the reverse order, the database reads a broad range first and then filters on the equality condition, resulting in an inefficient scan.

-- Query: status is an equality condition; created_at is a range
WHERE status = 'completed'
  AND created_at >= '2024-04-01' AND created_at < '2024-07-01'

-- ✗ INDEX (created_at, status): range first → cannot narrow by status
-- ✓ INDEX (status, created_at): equality first → scans only one contiguous range
CREATE INDEX idx_orders_status_created ON orders (status, created_at);
Leftmost-prefix principle: A composite index (a, b, c) can be used when conditions appear continuously from the beginning (a / a,b / a,b,c). It is generally of little use for conditions on only b or c. Also, columns after the one reached by a range condition cannot be used for “narrowing”; they are treated as filters. That is why “equality → range” is the rule of thumb.
Problem

From the orders table, retrieve orders with status = completed and a creation date 2024 (April–June). Also write the optimal composite-index CREATE INDEX statement for this query. Return order_id, created_at, amount, ordered by created_at ascending.

Source table
- orders (8 rows)
order_idstatuscreated_atamount
1completed2024-01-101200
2cancelled2024-04-02800
3completed2024-04-153000
4completed2024-05-202500
5pending2024-05-25900
6completed2024-06-304000
7completed2024-08-011100
8cancelled2024-06-10600
Expected Output
order_idcreated_atamount
32024-04-153000
42024-05-202500
62024-06-304000
Model Answer
CREATE INDEX idx_orders_status_created  -- Optimal composite index: equality column (status) first, range column (created_at) later
ON orders (status, created_at);

SELECT order_id, created_at, amount
FROM   orders
WHERE  status = 'completed'          -- Equality condition: pinpoint the leading index column
  AND  created_at >= '2024-04-01'    -- Range condition: scan a contiguous range in the second column (half-open interval)
  AND  created_at <  '2024-07-01'
ORDER BY created_at;                 -- Matches index order, so no sort is needed

/*
  Logical execution order:
  1. Binary search the B-tree by (status, created_at)  → reach the composite key
  2. Follow the leaves to the right in a contiguous scan  → scan only around the range
  3. Fetch columns from the table  → retrieve amount and other columns
  4. ORDER BY created_at  → no Sort node

  Plan comparison (conceptual):
  ✗ INDEX (created_at, status)
       → read all rows in April–June (id=2,3,4,5,6,8), then filter status
  ✓ INDEX (status, created_at)
       → read only April–June rows among completed orders in one range
*/
Explanation (table transitions & key points)
CREATE INDEX idx ON orders (status, created_at); SELECT order_id, created_at, amount FROM orders WHERE status = 'completed' AND created_at >= '2024-04-01' AND created_at < '2024-07-01' ORDER BY created_at;
LEGEND
Rows read / loaded
1. FROM orders — 8 rows (target of composite-index design)
FROM ordersFilter with the two conditions status (equality) and created_at (range). Even for a query returning the same 3 rows, the index column order changes the amount of scanning completely; that is the theme of this question.
1 / 4
order_idstatuscreated_atamount
1completed2024-01-101200
2cancelled2024-04-02800
3completed2024-04-153000
4completed2024-05-202500
5pending2024-05-25900
6completed2024-06-304000
7completed2024-08-011100
8cancelled2024-06-10600
8 rows (3 match)
LEARNING POINTS
“Equality first, range later” is the first principle of composite indexes: Because a B-tree is sorted from the leftmost column, first fix the block with an equality condition, then scan a contiguous range inside it for the lowest cost. If the range column comes first, all rows in the range are read before the equality condition filters them, and wasted reads grow depending on selectivity.
ORDER BY can also be “eliminated” by an index: Once status is fixed, INDEX (status, created_at) is ordered by created_at. Since WHERE status=... ORDER BY created_at can return that order directly, the Sort node disappears from EXPLAIN. Designing the filtering and ordering columns as a set is the advanced perspective.
Use the leftmost prefix to reduce the number of indexes: If (status, created_at) exists, it also serves queries with only WHERE status = ... (using just the leading column). Conversely, it is generally of little use for WHERE created_at = ... alone. Check whether an existing index can cover the query through its leftmost prefix before adding another index.
ANTI-PATTERNS
Scatter separate single-column indexes everywhere: Even if INDEX(status) and INDEX(created_at) are created separately, a composite query may use only one, or combine them with BitmapAnd and become slower than one composite index. Also, indexes are all updated on every write (INSERT/UPDATE), so having too many directly harms write performance.
Index only a column with extremely low cardinality: A single-column index such as INDEX(status), where there are only a few possible values, often has too high a hit rate per value and is ignored by the planner. Use a low-cardinality column as the equality “anchor” at the start of a composite index instead of relying on it alone.
Practical note: Design indexes backward from the query
Good indexes are not chosen by staring at a table; they are chosen by working backward from the WHERE / JOIN / ORDER BY clauses of frequent queries. The process is (1) identify slow queries with tools such as pg_stat_statements, (2) classify conditions as “equality / range / ordering”, (3) order columns as equality → range → (columns to cover with INCLUDE), and (4) compare before and after with EXPLAIN ANALYZE. If an existing index’s leftmost prefix is enough, do not add another; find unused indexes with pg_stat_user_indexes and remove them — designing what not to add is as important as designing what to add.
QUESTION 2

Keyset Pagination — Cursor-based pagination that stays fast on deep pages

KeysetAvoiding OFFSETTuple ComparisonPagination
Background

As discussed in the basic set, LIMIT 20 OFFSET 1000000 reads and discards one million rows before returning 20, so it gets linearly slower on deeper pages. The advanced solution is Keyset Pagination (cursor-based pagination): remember the key of the last row on the previous page and use a WHERE clause to specify the rows after that position directly. Every page can be retrieved with a single Index Scan, keeping the cost constant.

-- ✗ OFFSET: discarded reads increase with page depth O(offset)
SELECT ... ORDER BY created_at DESC, order_id DESC
LIMIT 3 OFFSET 3;

-- ✓ Keyset: continue directly from the previous page’s last key O(limit)
WHERE (created_at, order_id) < ('2024-06-05', 8)
ORDER BY created_at DESC, order_id DESC LIMIT 3;
Tuple comparison and tie-breaking: If rows share a created_at value, “where to continue” is ambiguous, so always include a unique key (order_id) in both sorting and comparison. (created_at, order_id) < (value1, value2) expresses “created_at is smaller, or created_at is equal and order_id is smaller” in one row-value (tuple) comparison, and composite index (created_at DESC, order_id DESC) can be used directly.
Problem

Retrieve the second page of a feed ordered by newest first (created_at DESC, order_id DESC, 3 rows per page). The last row on page 1 is (created_at, order_id) = ('2024-06-05', 8). Do not use OFFSET; write it with Keyset Pagination using tuple comparison. Return order_id, created_at, amount.

Source table
- orders (10 rows · INDEX (created_at DESC, order_id DESC))
order_idcreated_atamount
102024-06-302200
92024-06-211800
82024-06-053500
72024-06-051500
62024-05-18900
52024-05-024100
42024-04-11700
32024-03-222600
22024-02-141300
12024-01-05500
Expected Output
order_idcreated_atamount
72024-06-051500
62024-05-18900
52024-05-024100
Model Answer
SELECT order_id, created_at, amount
FROM   orders
WHERE  (created_at, order_id) < ('2024-06-05', 8)  -- [Cursor] after the previous page’s last key in sort order (tuple comparison)
ORDER BY created_at DESC, order_id DESC            -- Include the unique key to fully fix the order
LIMIT  3;                                          -- Fetch only one page and stop early

/*
  Logical execution order:
  1. Binary-search the index to the position after the cursor  → no discarded OFFSET rows
  2. Read only 3 rows in index order and stop  → LIMIT 3
  3. Return the SELECT columns  → return the requested columns

  Tuple-comparison expansion (equivalent condition):
  (created_at, order_id) < ('2024-06-05', 8)
  ⇔  created_at <  '2024-06-05'
   OR (created_at = '2024-06-05' AND order_id < 8)

  Plan comparison (conceptual):
  ✗ LIMIT 3 OFFSET 3 … read the first 6 rows and discard 3 (worse on deeper pages)
  ✓ Keyset          … read only 3 rows from the cursor position (always O(limit))
*/
Explanation (table transitions & key points)
SELECT order_id, created_at, amount FROM orders WHERE (created_at, order_id) < ('2024-06-05', 8) ORDER BY created_at DESC, order_id DESC LIMIT 3;
LEGEND
Rows read / loaded
1. FROM orders — entire feed (sorted index)
FROM ordersOn INDEX (created_at DESC, order_id DESC), rows are physically ordered as in this table. Page 1 is the first 3 rows (id=10, 9, 8). Notice that id=8 and 7 share the same created_at.
1 / 5
order_idcreated_atamount
102024-06-302200
92024-06-211800
82024-06-053500
72024-06-051500
62024-05-18900
52024-05-024100
42024-04-11700
32024-03-222600
22024-02-141300
12024-01-05500
10 rows (index order = display order)
LEARNING POINTS
OFFSET means “read then discard”; Keyset means “never read them”: No matter how effective an index is, OFFSET N always scans the first N rows. Keyset uses a B-tree binary search to jump directly to the cursor, so page 1 and page one million cost the same. “O(1) with respect to page depth” is the core value of this method.
Always include a unique key in both sorting and comparison: If the cursor uses only created_at, rows with the same timestamp may be duplicated or skipped (the id=7 and 8 case). Write ORDER BY created_at DESC, order_id DESC and (created_at, order_id) < (...) as a pair to make the order completely unique.
Tuple comparison is friendlier to indexes than its expanded form: (a, b) < (x, y) is equivalent to a < x OR (a = x AND b < y), but the tuple form is easier for the planner to treat as one composite-index range (PostgreSQL directly supports row-value comparison). Some databases produce a worse plan for the OR expansion, so use tuple form when supported.
ANTI-PATTERNS
Run COUNT(*) on every request just to display the total page count: Running SELECT COUNT(*) FROM huge_table on every request for a pagination UI can make the count more expensive than the main query. Infinite scroll does not need a total; when a count is necessary, estimates such as pg_class.reltuples or a cache are practical alternatives.
Use only a non-unique column as the cursor: A cursor such as WHERE created_at < 'the previous page’s last timestamp' is a source of bugs because it skips or duplicates rows sharing that timestamp. This happens especially often when batch ingestion records created_at only to the second. Build the cursor as a tuple including the primary key.
Practical note: When OFFSET cannot be abandoned completely
Keyset is fast, but it cannot jump to an arbitrary page number because it needs the previous page’s cursor. In practice, a common split is Keyset for infinite scroll, API pagination, and batch reads, while OFFSET is acceptable for a management screen’s “go to page N” UI when pages are shallow. At larger scale, a product rule such as “allow OFFSET up to page 100; beyond that, require search filters” is also a legitimate performance measure. The advanced perspective is to redesign the UI specification itself, not only rewrite the SQL.
QUESTION 3

Top-N per Group with Window Functions — Get the top 2 in each category in one pass with ROW_NUMBER

ROW_NUMBERPARTITION BYCTETop-N per Group
Background

The basic Top-N question selected the top K rows overall, but in real work the much more common case is “top K rows in each group” (Top-N per Group). A correlated subquery tends to repeat a scan once per group, whereas the window function ROW_NUMBER() needs only one table pass plus sorting within each partition. Window functions cannot be used directly in WHERE, so the standard pattern is to assign ranks in a CTE (or subquery) and filter in the outer query.

-- Standard pattern: rank in a CTE → filter rn <= K outside
WITH ranked AS (
  SELECT *,
         ROW_NUMBER() OVER (
           PARTITION BY category
           ORDER BY amount DESC) AS rn
  FROM orders)
SELECT ... WHERE rn <= 2;
ROW_NUMBER / RANK / DENSE_RANK differ: They handle ties differently. ROW_NUMBER is always sequential (1,2,3... even for ties), RANK gives ties the same rank and skips the next rank (1,1,3), while DENSE_RANK gives ties the same rank without a gap (1,1,2). Choose ROW_NUMBER + a unique tie-breaker when you need exactly K rows, and RANK when you want all tied rows.
Problem

From the orders table, retrieve the top 2 orders by sales amount in each category. When amounts tie, prefer the smaller order_id. Return category, order_id, amount, rn, ordered by category ascending and then rn ascending.

Source table
- orders (9 rows · 3 categories)
order_idcategoryamount
1Books1200
2Books800
3Books2000
4Toys5000
5Toys3000
6Toys4500
7Games2500
8Games2500
9Games900
Expected Output
categoryorder_idamountrn
Books320001
Books112002
Games725001
Games825002
Toys450001
Toys645002
Model Answer
WITH ranked AS (
  SELECT category, order_id, amount,
         ROW_NUMBER() OVER (
           PARTITION BY category           -- Create an independent numbering space for each category
           ORDER BY amount DESC, order_id  -- Amount descending + unique key makes the ranking deterministic
         ) AS rn
  FROM orders
)
SELECT category, order_id, amount, rn
FROM   ranked
WHERE  rn <= 2                             -- Window functions cannot be used directly in WHERE; filter outside
ORDER BY category, rn;

/*
  Logical execution order:
  1. FROM orders                          → read 9 rows in one pass
  2. PARTITION BY category                → divide into category partitions
  3. ORDER BY amount DESC, order_id within each partition  → sort within partitions
  4. ROW_NUMBER() assigns sequential numbers                   → assign rn
  5. Outer WHERE rn filters the top rows                    → only the top 2 rows in each partition pass
  6. ORDER BY category, rn                → final ordering

  Tie-breaker effect (Games):
    id=7 (2500) and id=8 (2500) have the same amount
    → ORDER BY amount DESC, order_id fixes id=7 at rn=1 and id=8 at rn=2
*/
Explanation (table transitions & key points)
WITH ranked AS ( SELECT category, order_id, amount, ROW_NUMBER() OVER ( PARTITION BY category ORDER BY amount DESC, order_id) AS rn FROM orders ) SELECT category, order_id, amount, rn FROM ranked WHERE rn <= 2 ORDER BY category, rn;
LEGEND
Rows read / loaded
1. FROM orders — read 9 rows in one pass
FROM ordersThere are 3 categories × 3 rows each. A correlated subquery would repeat an inner scan for the 9 outer rows, but the window function reads every row only once.
1 / 5
order_idcategoryamount
1Books1200
2Books800
3Books2000
4Toys5000
5Toys3000
6Toys4500
7Games2500
8Games2500
9Games900
9 rows × 3 columns
LEARNING POINTS
GROUP BY “folds”; window functions do not: GROUP BY aggregates rows by group, while window functions attach a group-level calculation to every row without changing the row count. When you need detail rows along with rank, a running total, or a comparison with the previous row, window functions are the right tool.
Window functions cannot be used in WHERE — add a CTE layer: In SQL’s logical evaluation order, WHERE → SELECT (window evaluation), so writing WHERE rn <= 2 at the same level causes an error. The two-layer structure of fixing rn in a CTE or subquery and then filtering outside is the established Top-N per Group pattern.
Add a unique key to ORDER BY to make the result deterministic: With tied amounts, ROW_NUMBER without a tie-breaker can change ranks between executions. That causes flaky tests and false-positive diff detection, so always fix the order with a unique key such as ORDER BY amount DESC, order_id.
ANTI-PATTERNS
Write Top-N per Group with a correlated subquery: A form such as WHERE amount >= (SELECT ... ORDER BY amount DESC LIMIT 1 OFFSET 1) evaluates the subquery repeatedly for each outer row. It collapses as group and row counts grow, so replace it with a window function (or, in PostgreSQL, LATERAL + LIMIT).
Use RANK when you need exactly K rows: RANK() <= 2 can return 3 or more rows when there are many ties, despite the expectation of 2. Passing that directly into a UI with room for only 2 rows causes layout problems and pagination mismatches. Use ROW_NUMBER when the count must be guaranteed and RANK when all ties are required.
Practical note: Indexes that support window-function performance
Even if the window function makes one pass, the sort cost for PARTITION BY + ORDER BY remains. This is where composite index returns: with INDEX (category, amount DESC, order_id), data is already ordered by “partition → rank within partition”, allowing an index scan to take over the sort. If there are few partitions and each is huge, PostgreSQL can sometimes win with LATERAL (SELECT ... LIMIT 2), which stops Top-N processing per partition. The advanced practice is to compare both approaches with EXPLAIN rather than treating window functions as the only option.
QUESTION 4

Pre-Join Aggregation — Prevent row fan-out and double counting by aggregating before JOIN

Pre-Join AggregationLEFT JOINCOALESCEAvoiding Row Fan-Out
Background

When a one-to-many table is joined, the parent row fans out once per child row. Aggregating after that expansion not only increases the amount of work; joining multiple child tables at once also creates a double-counting bug. The advanced standard pattern is to aggregate the child table in a CTE first, then JOIN the smaller result to the parent. To retain parents with zero children, combine LEFT JOIN with COALESCE.

-- ✗ Aggregate after joining: fold only after rows have fanned out (heavy and error-prone)
SELECT c.name, COUNT(o.order_id), SUM(o.amount)
FROM customers c LEFT JOIN orders o ON ... GROUP BY c.name;

-- ✓ Aggregate before joining: fold the child first, then JOIN the small result
WITH agg AS (SELECT customer_id, COUNT(*) cnt, SUM(amount) total
            FROM orders GROUP BY customer_id)
SELECT ... FROM customers c LEFT JOIN agg ON ...;
Why is “fold first” faster: JOIN cost is roughly affected by the product (or sum) of the row counts on both sides. Folding one million orders to one row per customer first drastically reduces the number of rows participating in the JOIN. If the aggregation key is the same as the JOIN key, one index can support both aggregation and joining. The advanced-set motto is “make the JOIN small first.”
Problem

From customers and orders, retrieve the order count and total amount for every customer. Aggregate orders in a CTE before joining, and output customers with no orders (Bob) as 0 / 0. Return customer_id, name, order_count, total_amount, ordered by customer_id ascending.

Source tables
- customers (3 rows)
customer_idname
101Alice
102Bob
103Carol
- orders (6 rows)
order_idcustomer_idamount
11011200
2101800
31035000
41033000
5103500
61012500
Expected Output
customer_idnameorder_counttotal_amount
101Alice34500
102Bob00
103Carol38500
Model Answer
WITH order_agg AS (
  SELECT   customer_id,
           COUNT(*)    AS order_count,   -- Fix the child-table count before the JOIN
           SUM(amount) AS total_amount   -- Compute the total in the same pass
  FROM     orders
  GROUP BY customer_id                  -- Fold 6 rows into 2 before joining
)
SELECT c.customer_id,
       c.name,
       COALESCE(a.order_count, 0)  AS order_count,  -- Customer with no orders: NULL → 0
       COALESCE(a.total_amount, 0) AS total_amount
FROM   customers c
LEFT JOIN order_agg a                  -- LEFT JOIN keeps every customer (does not drop Bob)
       ON a.customer_id = c.customer_id
ORDER BY c.customer_id;

/*
  Logical execution order:
  1. CTE order_agg                       → aggregate by customer_id
  2. FROM customers LEFT JOIN order_agg  → join the aggregate (unmatched side is NULL)
  3. SELECT applies COALESCE              → convert NULL to 0
  4. ORDER BY customer_id                → order the rows

  Plan comparison (conceptual):
  ✗ JOIN then GROUP BY → the join intermediate result fans out (all orders participate)
  ✓ Aggregate then JOIN → only the “customer count” rows participate in the join
*/
Explanation (table transitions & key points)
WITH order_agg AS ( SELECT customer_id, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders GROUP BY customer_id ) SELECT c.customer_id, c.name, COALESCE(a.order_count, 0), COALESCE(a.total_amount, 0) FROM customers c LEFT JOIN order_agg a ON a.customer_id = c.customer_id ORDER BY c.customer_id;
LEGEND
Grouping keys / aggregation targets
Group classification
1. FROM orders — child table to aggregate (6 rows)
WITH order_agg AS (... FROM orders ...)Fold the “many” side, orders, first. Grouping by customer_id shows 3 rows for 101, 3 rows for 103, and 0 rows for 102 (Bob).
1 / 5
order_idcustomer_idamount
11011200
2101800
61012500
31035000
41033000
5103500
6 rows (101 × 3, 103 × 3, 102 has 0 rows)
LEARNING POINTS
“Aggregate before JOIN” gives you both speed and correctness: Folding the child table before the JOIN simultaneously (1) reduces the rows participating in the JOIN and makes it faster, and (2) prevents aggregate values from multiplying even when multiple child tables are joined (no double counting). It is essential for reports with two or more one-to-many relationships.
Three-part set for zero-order parents — LEFT JOIN + pre-aggregation + COALESCE: Counting with COUNT(*) after a JOIN turns a zero-order customer such as Bob into “1 order” because a NULL row is still one row. COUNT(o.order_id) also avoids it, but pre-aggregation + COALESCE makes the counting mistake structurally impossible.
Align the aggregation key with the JOIN key so one index helps twice: If the CTE’s GROUP BY customer_id and the outer ON a.customer_id = c.customer_id use the same key, one orders(customer_id) index can help avoid aggregation sorting and support JOIN lookup. Aligning key design tightens the entire plan.
ANTI-PATTERNS
Join multiple one-to-many tables and then SUM: If you build customers ⨝ orders ⨝ payments and then run SUM(o.amount), each order row is duplicated once per payment and the classic over-counting bug occurs. Many “why did the number suddenly double?” incidents come from this. Aggregate each child table independently before joining.
Use a correlated subquery in SELECT to count: Although SELECT c.name, (SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.customer_id) ... is readable, the subquery executes repeatedly for every customer row. It may be acceptable with few customers, but list queries should use pre-aggregation + LEFT JOIN as the standard pattern.
Practical note: If it is still slow, precompute the aggregation
If a dashboard remains slow even after exhausting pre-join aggregation, the next move is a materialized view (materialized aggregate results). Store this query’s result physically as CREATE MATERIALIZED VIEW customer_sales AS WITH order_agg AS (...) SELECT ... and run REFRESH MATERIALIZED VIEW CONCURRENTLY in a nightly batch or every few minutes; the read side then only reads a small ordinary table. The trade-off is freshness. If real-time data is essential, use an aggregate table with triggers or incremental updates; if a delay of a few minutes is acceptable, use a materialized view. Choose from the freshness requirement rather than from SQL alone.
QUESTION 5

Recursive CTE Performance Management — Safely and quickly traverse hierarchical data with a depth limit

WITH RECURSIVEUNION ALLDepth LimitCycle Prevention
Background

Use WITH RECURSIVE to follow parent-child relationships to arbitrary depths in category hierarchies, organization charts, bills of materials (BOMs), and similar data. The process is: the non-recursive term (seed) creates the initial rows, the recursive term repeatedly runs against the immediately preceding result (the working table), and stops when no new rows appear. Performance management has two key points: use a depth limit (depth guard) to contain tree explosion, and prevent infinite loops with cycle protection.

WITH RECURSIVE tree AS (
  -- Non-recursive term (seed): the one starting row
  SELECT id, name, parent_id, 0 AS depth FROM categories WHERE id = 1
  UNION ALL
  -- Recursive term: find only the children of rows t from the immediately preceding step
  SELECT c.id, c.name, c.parent_id, t.depth + 1
  FROM categories c JOIN tree t ON c.parent_id = t.id
  WHERE t.depth < 2          -- Depth-limit guard
)
SELECT * FROM tree;
The recursive term sees only “the rows added immediately before”, not the entire accumulated result: In each iteration, the JOIN side is the rows added in the previous iteration (the working table). Processing therefore proceeds breadth-first by generation and naturally stops at a generation with no children. Once this is understood, it becomes clear that an index on parent_id is the key support for the JOIN in every generation.
Problem

From the categories table, retrieve all categories beneath id=1 (Electronics) with their depth. For safety, add a depth guard of depth < 2 (maximum depth 2). Return id, name, depth, ordered by depth ascending and then id ascending.

Source table
- categories (6 rows · index on parent_id)
idnameparent_id
1ElectronicsNULL
2Computers1
3Laptops2
4Audio1
5Headphones4
6BooksNULL
Expected Output
idnamedepth
1Electronics0
2Computers1
4Audio1
3Laptops2
5Headphones2
Model Answer
WITH RECURSIVE tree AS (
  SELECT id, name, parent_id, 0 AS depth  -- [Seed] Starting Electronics (depth=0)
  FROM   categories
  WHERE  id = 1

  UNION ALL  -- No duplicate removal is needed, so UNION ALL is lighter than UNION

  SELECT c.id, c.name, c.parent_id, t.depth + 1
  FROM   categories c
  JOIN   tree t ON c.parent_id = t.id      -- Explore only children of the immediately preceding generation (needs parent_id index)
  WHERE  t.depth < 2                         -- [Depth limit] Safety valve for runaway queries and bad data
)
SELECT   id, name, depth
FROM     tree
ORDER BY depth, id;

/*
  Logical execution order (generation by generation):
  1. Run the seed      → place the starting row in the working table
  2. First recursion    → find and add children through parent_id
  3. Second recursion   → find and add the next children
  4. Third recursion    → stop at the guard (0 new rows)
  5. Outer SELECT       → order the accumulated result by depth and id
  */
Explanation (table transitions & key points)
WITH RECURSIVE tree AS ( SELECT id, name, parent_id, 0 AS depth FROM categories WHERE id = 1 UNION ALL SELECT c.id, c.name, c.parent_id, t.depth + 1 FROM categories c JOIN tree t ON c.parent_id = t.id WHERE t.depth < 2 ) SELECT id, name, depth FROM tree ORDER BY depth, id;
LEGEND
1. Seed — place the starting row with the non-recursive term (depth=0)
SELECT ... WHERE id = 1 (non-recursive term)The starting point of the recursion. The one Electronics row is both the “accumulated result” and the parent (working table) for the next search. The rows with parent_id = 1 are previewed as the next generated rows with dotted styling.
1 / 5
idnamedepthStatus
1Electronics0Added now (seed)
2Computers1 (planned)Generated next
4Audio1 (planned)Generated next
Accumulated: 1 row / next parent candidate: id=1
LEARNING POINTS
Recursive CTEs proceed breadth-first by generation: In each iteration, the JOIN side is only the previous generation’s rows, not the entire accumulated result. The result expands one generation at a time — seed → children → grandchildren — and automatically stops at a generation with 0 new rows. With this structure in mind, you can estimate cost from depth and branching factor.
Add a depth guard even when the data is expected to be correct: A single operational mistake can create a parent-child cycle and an infinite loop that keeps consuming CPU (A→B→A). One line, WHERE t.depth < N, protects the database from runaway queries when data is abnormal. PostgreSQL 14+ also supports declarative cycle detection with CYCLE id SET is_cycle USING path.
UNION ALL and the parent_id index are the performance keys: Recursive CTEs generally use UNION ALL rather than UNION (which removes duplicates): tree structures do not produce duplicates, so UNION ALL avoids the hash de-duplication cost in every generation. The recursive term’s c.parent_id = t.id runs once per generation, so without a categories(parent_id) index the worst case is number of generations × Seq Scan.
ANTI-PATTERNS
Put unguarded recursion in production: “The data is clean, so it is fine” is not enough. One circular reference can make the query run indefinitely and consume CPU. Add at least one of a depth limit, cycle detection, or statement_timeout — preferably several — before deploying to production.
Run one SELECT per level in an application loop: Fetching the parent, then its children, and so on in application code creates round trips equal to depth × number of nodes, a hierarchical version of N+1. One recursive CTE completes inside the database with one network round trip.
Practical note: Advanced-set summary — 5 principles for designing the plan
The basic set’s 5 principles were about avoiding bad ways to write queries; the advanced set is about designing the execution plan itself. 1. Design composite indexes from equality to range and through ordering (Q1), 2. Make pagination independent of depth with a cursor (Q2), 3. Make group ranking one pass with window functions (Q3), 4. Fold before JOIN — put aggregation before the join (Q4), 5. Understand recursion as a generation model and protect it with a guard (Q5). The shared idea is to reduce the total work handed to the database through query structure. Change the structure before adding indexes, then measure with EXPLAIN ANALYZE — being able to repeat this loop is practical skill at the advanced level.