Performance Optimization — Learn Composite Indexes, Keyset, Pre-Join Aggregation, and Recursive CTEs in Practice
AdvancedComposite IndexesKeyset PaginationPre-Join AggregationRecursive CTETop-N per GroupPostgreSQL-compatible5 questions
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 in Q2 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

※ The matching rows are id=3, 4, and 6 (3 rows). As in the basic set, write the boundary as the half-open interval >= '2024-04-01' AND < '2024-07-01'.

Expected Output

Expected output (created_at ascending):

order_idcreated_atamount
32024-04-153000
42024-05-202500
62024-06-304000

With INDEX (status, created_at), ORDER BY created_at can also be satisfied directly in index order, eliminating the sort itself.

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

※ id=7 and id=8 share created_at (2024-06-05). Page 1 is id=10, 9, 8. The tie makes the order_id tie-breaker and tuple comparison essential.

Expected Output

Expected output (page 2 · 3 rows):

order_idcreated_atamount
72024-06-051500
62024-05-18900
52024-05-024100

id=7 has the same created_at as the cursor, but order_id 7 < 8, so it passes the tuple comparison and is first on page 2.

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

※ Games has a tie: id=7 and 8 both have amount 2500. Without order_id as a tie-breaker in ORDER BY, the ranks can swap between executions and the result is nondeterministic.

Expected Output

Expected output (category ascending · 2 rows per category):

categoryorder_idamountrn
Books320001
Books112002
Games725001
Games825002
Toys450001
Toys645002

9 rows → 6 rows. Even as the number of groups grows, this can be processed in one pass and avoids the repeated scans of a correlated subquery.

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

※ Alice = 3 orders / 4500, Carol = 3 orders / 8500, Bob = 0 orders. With INNER JOIN Bob disappears, and COUNT(*) turns Bob into “1 order”, a classic trap.

Expected Output

Expected output (customer_id ascending):

customer_idnameorder_counttotal_amount
101Alice34500
102Bob00
103Carol38500

The orders side contributes only 2 rows after aggregation. Even with one million orders, JOIN cost is kept close to “customer count × 1”.

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

※ Tree structure: 1 (Electronics) ─ 2 (Computers) ─ 3 (Laptops), and 1 ─ 4 (Audio) ─ 5 (Headphones). id=6 (Books) is a separate tree and is not included.

Expected Output

Expected output (depth ascending → id ascending):

idnamedepth
1Electronics0
2Computers1
4Audio1
3Laptops2
5Headphones2

Depth-2 rows are generated, but the guard (t.depth < 2) prevents the next search from using a depth-2 row as its parent, so recursion stops there.