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);
(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.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.
| order_id | status | created_at | amount |
|---|---|---|---|
| 1 | completed | 2024-01-10 | 1200 |
| 2 | cancelled | 2024-04-02 | 800 |
| 3 | completed | 2024-04-15 | 3000 |
| 4 | completed | 2024-05-20 | 2500 |
| 5 | pending | 2024-05-25 | 900 |
| 6 | completed | 2024-06-30 | 4000 |
| 7 | completed | 2024-08-01 | 1100 |
| 8 | cancelled | 2024-06-10 | 600 |
※ 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 (created_at ascending):
| order_id | created_at | amount |
|---|---|---|
| 3 | 2024-04-15 | 3000 |
| 4 | 2024-05-20 | 2500 |
| 6 | 2024-06-30 | 4000 |
With INDEX (status, created_at), ORDER BY created_at can also be satisfied directly in index order, eliminating the sort itself.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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;
(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.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.
| order_id | created_at | amount |
|---|---|---|
| 10 | 2024-06-30 | 2200 |
| 9 | 2024-06-21 | 1800 |
| 8 | 2024-06-05 | 3500 |
| 7 | 2024-06-05 | 1500 |
| 6 | 2024-05-18 | 900 |
| 5 | 2024-05-02 | 4100 |
| 4 | 2024-04-11 | 700 |
| 3 | 2024-03-22 | 2600 |
| 2 | 2024-02-14 | 1300 |
| 1 | 2024-01-05 | 500 |
※ 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 (page 2 · 3 rows):
| order_id | created_at | amount |
|---|---|---|
| 7 | 2024-06-05 | 1500 |
| 6 | 2024-05-18 | 900 |
| 5 | 2024-05-02 | 4100 |
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.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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 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.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.
| order_id | category | amount |
|---|---|---|
| 1 | Books | 1200 |
| 2 | Books | 800 |
| 3 | Books | 2000 |
| 4 | Toys | 5000 |
| 5 | Toys | 3000 |
| 6 | Toys | 4500 |
| 7 | Games | 2500 |
| 8 | Games | 2500 |
| 9 | Games | 900 |
※ 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 (category ascending · 2 rows per category):
| category | order_id | amount | rn |
|---|---|---|---|
| Books | 3 | 2000 | 1 |
| Books | 1 | 1200 | 2 |
| Games | 7 | 2500 | 1 |
| Games | 8 | 2500 | 2 |
| Toys | 4 | 5000 | 1 |
| Toys | 6 | 4500 | 2 |
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.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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 ...;
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.
| customer_id | name |
|---|---|
| 101 | Alice |
| 102 | Bob |
| 103 | Carol |
| order_id | customer_id | amount |
|---|---|---|
| 1 | 101 | 1200 |
| 2 | 101 | 800 |
| 3 | 103 | 5000 |
| 4 | 103 | 3000 |
| 5 | 103 | 500 |
| 6 | 101 | 2500 |
※ 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 (customer_id ascending):
| customer_id | name | order_count | total_amount |
|---|---|---|---|
| 101 | Alice | 3 | 4500 |
| 102 | Bob | 0 | 0 |
| 103 | Carol | 3 | 8500 |
The orders side contributes only 2 rows after aggregation. Even with one million orders, JOIN cost is kept close to “customer count × 1”.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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;
parent_id is the key support for the JOIN in every generation.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.
| id | name | parent_id |
|---|---|---|
| 1 | Electronics | NULL |
| 2 | Computers | 1 |
| 3 | Laptops | 2 |
| 4 | Audio | 1 |
| 5 | Headphones | 4 |
| 6 | Books | NULL |
※ 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 (depth ascending → id ascending):
| id | name | depth |
|---|---|---|
| 1 | Electronics | 0 |
| 2 | Computers | 1 |
| 4 | Audio | 1 |
| 3 | Laptops | 2 |
| 5 | Headphones | 2 |
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.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free