When extracting a period such as “orders in May 2026,” writing date_trunc('month', ordered_at) = '2026-05-01' wraps the column in a function and prevents the index from being used (a Sargable violation). Moreover, BETWEEN '2026-05-01' AND '2026-05-31' is dangerous for a timestamp column: every row after May 31 at 00:00:00 is missed. The correct form is a half-open interval (greater than or equal to the start, less than the end).
-- ✗ Wrap the column in a function → function evaluation for every row = Seq Scan WHERE date_trunc('month', ordered_at) = '2026-05-01' -- ✗ BETWEEN includes both endpoints → rows after 5/31 00:00:00 are missed WHERE ordered_at BETWEEN '2026-05-01' AND '2026-05-31' -- ✓ Half-open interval: the index works and the month-end boundary is exact WHERE ordered_at >= '2026-05-01' AND ordered_at < '2026-06-01'
The orders table (3 million actual rows) has an index idx_orders_ordered_at on ordered_at (timestamp). Retrieve orders from May 2026 in ordered_at ascending order, with the index usable and every fractional second at month-end included. Return order_id, ordered_at, amount.
| order_id | ordered_at | amount |
|---|---|---|
| 1 | 2026-04-30 23:59:59 | 700 |
| 2 | 2026-05-01 00:00:00 | 3400 |
| 3 | 2026-05-14 12:30:00 | 800 |
| 4 | 2026-05-20 18:05:00 | 950 |
| 5 | 2026-05-31 23:59:59.9 | 2600 |
| 6 | 2026-06-01 00:00:00 | 1500 |
※ order_id=5 is May 31 at 23:59:59.9. Also consider what happens to this row with BETWEEN ... AND '2026-05-31'.
Expected output (ordered_at ascending):
| order_id | ordered_at | amount |
|---|---|---|
| 2 | 2026-05-01 00:00:00 | 3400 |
| 3 | 2026-05-14 12:30:00 | 800 |
| 4 | 2026-05-20 18:05:00 | 950 |
| 5 | 2026-05-31 23:59:59.9 | 2600 |
With a half-open interval, only the contiguous index range is read, and the row right at month-end (id=5) is included exactly.
- 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
A composite index (A, B) is like a telephone book: it is ordered by A, then by B among rows with the same A (family name → given name). The condition shape that makes the most of this structure is “equality → range → sort”: fix the first column with equality (=), and the next column becomes a neatly ordered contiguous range inside it, allowing both the range condition and ORDER BY to use the index.
-- For index (status, shipped_at): WHERE status = 'shipped' -- 1. Fix the first column with equality (one interval point) AND shipped_at >= '2026-06-01' -- 2. Range scan inside the fixed interval ORDER BY shipped_at -- 3. Already ordered inside the interval → no sort
The shipments table (2 million actual rows) has a composite index idx_ship_status_date (status, shipped_at). Retrieve records where status is 'shipped' and shipment date is on or after June 1, 2026, with both filtering and sorting completed using only this index, ordered by shipped_at ascending. Return ship_id, status, shipped_at, dest.
| ship_id | status | shipped_at | dest |
|---|---|---|---|
| 1 | pending | 2026-06-02 | Tokyo |
| 2 | shipped | 2026-05-28 | Osaka |
| 3 | shipped | 2026-06-01 | Tokyo |
| 4 | delivered | 2026-06-03 | Nagoya |
| 5 | shipped | 2026-06-04 | Sapporo |
| 6 | pending | 2026-05-30 | Fukuoka |
| 7 | shipped | 2026-06-09 | Tokyo |
| 8 | delivered | 2026-06-05 | Kobe |
※ Also consider how the scan would change if the index were reversed to (shipped_at, status).
Expected output (shipped_at ascending):
| ship_id | status | shipped_at | dest |
|---|---|---|---|
| 3 | shipped | 2026-06-01 | Tokyo |
| 5 | shipped | 2026-06-04 | Sapporo |
| 7 | shipped | 2026-06-09 | Tokyo |
Equality + range + ORDER BY all use one index, so the Sort node disappears from the execution plan.
- 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 latest N rows per customer” is one of the most common practical requirements, but it is surprisingly difficult to write efficiently. A window function such as ROW_NUMBER() can express it, but when there are few target customers, scanning and sorting every row is excessive. With LATERAL, a subquery can receive each row on the left as an argument, making the minimal scan possible: for each customer, use the index to pick only the latest two orders and stop immediately.
FROM customers c CROSS JOIN LATERAL ( -- The subquery runs for each row of c SELECT ... FROM orders WHERE customer_id = c.customer_id -- The outer column is visible inside (LATERAL’s privilege) ORDER BY ordered_at DESC LIMIT 2 -- Stop at two rows per customer ) o
(customer_id, ordered_at DESC), each subquery fixes the customer interval by equality, then reads two rows from the already ordered start and stops (the Q2 application). Even with one million orders, the scan is only customer count × 2 rows.From customers (3 rows) and orders (1 million actual rows, with composite index idx_orders_cust_date (customer_id, ordered_at DESC)), retrieve the latest 2 orders for each customer with a LATERAL JOIN. Return customer_id, name, order_id, ordered_at, amount, ordered by customer_id ascending and ordered_at descending.
| customer_id | name |
|---|---|
| 101 | Sato |
| 102 | Suzuki |
| 103 | Tanaka |
| order_id | customer_id | ordered_at | amount |
|---|---|---|---|
| 1 | 101 | 2026-06-01 | 1200 |
| 2 | 102 | 2026-06-02 | 800 |
| 3 | 101 | 2026-06-03 | 3000 |
| 4 | 103 | 2026-06-04 | 500 |
| 5 | 101 | 2026-06-05 | 900 |
| 6 | 102 | 2026-06-06 | 1500 |
| 7 | 103 | 2026-06-07 | 700 |
| 8 | 101 | 2026-06-08 | 2200 |
| 9 | 102 | 2026-06-09 | 600 |
※ Customer 101 has four orders. The strength of LATERAL + LIMIT is that the two older orders (ids 1 and 3) are not scanned at all.
Expected output (customer_id ascending, ordered_at descending):
| customer_id | name | order_id | ordered_at | amount |
|---|---|---|---|---|
| 101 | Sato | 8 | 2026-06-08 | 2200 |
| 101 | Sato | 5 | 2026-06-05 | 900 |
| 102 | Suzuki | 9 | 2026-06-09 | 600 |
| 102 | Suzuki | 6 | 2026-06-06 | 1500 |
| 103 | Tanaka | 7 | 2026-06-07 | 700 |
| 103 | Tanaka | 4 | 2026-06-04 | 500 |
- 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
“For each payment method, report the total count, success count, and sum of failed amounts.” This is a reporting requirement with several aggregations over different conditions. Scanning the table three times and joining the results is the worst approach. PostgreSQL’s FILTER clause lets each aggregate function select its own input rows within one scan.
-- ✗ Repeat scans and join one subquery per condition (3 passes) SELECT ... FROM (success only) JOIN (failure only) ON ... -- ✓ FILTER: select rows per aggregate inside one pass COUNT(*) FILTER (WHERE status = 'ok') -- Only this COUNT counts ok rows SUM(amount) FILTER (WHERE status = 'ng') -- Only this SUM adds ng rows
From the payments table (8 million actual rows), aggregate by payment method in one scan: report the total count cnt, success count ok_cnt (status='ok'), and failed amount total ng_amount (sum of amount where status='ng'; use 0 when there are no matching rows). Return only methods with at least 2 rows, ordered by method ascending.
| pay_id | method | status | amount |
|---|---|---|---|
| 1 | card | ok | 1200 |
| 2 | bank | ok | 5000 |
| 3 | card | ng | 800 |
| 4 | card | ok | 2400 |
| 5 | paypay | ok | 600 |
| 6 | bank | ng | 3000 |
| 7 | card | ng | 500 |
| 8 | bank | ok | 7000 |
※ paypay has only one row, so it drops from the final output. The question is which layer (WHERE / FILTER / HAVING) should contain the at-least-two-rows condition.
Expected output (method ascending):
| method | cnt | ok_cnt | ng_amount |
|---|---|---|---|
| bank | 3 | 2 | 3000 |
| card | 4 | 2 | 1300 |
The table is scanned once. Three aggregates run alongside one another in the same pass.
- 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
In tables such as job queues and order statuses, the search target can become a tiny “unprocessed (pending)” minority while 99% of rows are already processed. A normal index on every row keeps paying size and update costs for that 99%. A partial index, written as CREATE INDEX ... WHERE condition, indexes only the rows matching the condition and stays small.
-- Index only pending rows (1% of the total) CREATE INDEX idx_tasks_pending ON tasks (created_at) WHERE status = 'pending'; -- ← This predicate defines the index’s coverage
WHERE status = 'pending' enables it, while WHERE status IN ('pending', 'done') does not. The planner checks whether “the index predicate ⊇ the query’s restriction” holds.For the tasks table (1 million actual rows, with about 1% having status = 'pending'): (1) create a partial index on pending rows ordered by created_at, and (2) use it to retrieve the 3 oldest pending tasks. Return task_id, created_at, title.
| task_id | status | created_at | title |
|---|---|---|---|
| 1 | done | 2026-05-01 | Create invoice |
| 2 | pending | 2026-05-03 | Inventory count |
| 3 | done | 2026-05-04 | Monthly report |
| 4 | pending | 2026-05-06 | Contract review |
| 5 | canceled | 2026-05-08 | Legacy site update |
| 6 | pending | 2026-05-10 | Prepare audit materials |
| 7 | done | 2026-05-12 | Schedule interviews |
| 8 | pending | 2026-05-13 | Price change notice |
※ A normal index (status, created_at) also works, but consider the difference in size and update cost compared with a partial index.
Expected output (created_at ascending, 3 rows):
| task_id | created_at | title |
|---|---|---|
| 2 | 2026-05-03 | Inventory count |
| 4 | 2026-05-06 | Contract review |
| 6 | 2026-05-10 | Prepare audit materials |
The index contains only about 10,000 pending rows, and LIMIT ends the scan after its first three entries.
- 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