Queries such as the latest 3 orders for each user are common in practice, but performance can differ by orders of magnitude depending on how they are written. The key is knowing how to choose among three representative forms based on data volume and index design.
-- [NG-1] Correlated subquery: ORDER BY + LIMIT runs for each outer row (an N+1 derivative) SELECT u.user_id, (SELECT array_agg(o.order_id) FROM ( SELECT order_id FROM orders WHERE user_id = u.user_id ORDER BY ordered_at DESC LIMIT 3) o) FROM users u; -- [NG-2] Window function: read all orders, sort by PARTITION, then filter with rn<=3 SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ordered_at DESC) AS rn FROM orders) x WHERE rn <= 3; -- ✓ LATERAL: read only the first 3 rows from index (user_id, ordered_at DESC) for each user SELECT u.user_id, o.order_id, o.ordered_at FROM users u CROSS JOIN LATERAL ( SELECT order_id, ordered_at FROM orders WHERE user_id = u.user_id ORDER BY ordered_at DESC LIMIT 3) o;
From users (100,000 rows) and orders (50 million rows, with index (user_id, ordered_at DESC)), retrieve the latest 3 orders for each user. Write it so that it does not scan all of orders and reads at most 3 rows per user. Return user_id, name, order_id, ordered_at (user_id ascending, and ordered_at descending within each user). Users with zero orders may be excluded.
| user_id | name |
|---|---|
| 1 | Sato |
| 2 | Suzuki |
| 3 | Tanaka |
| order_id | user_id | ordered_at |
|---|---|---|
| 101 | 1 | 2026-05-01 |
| 102 | 1 | 2026-05-03 |
| 103 | 1 | 2026-05-05 |
| 104 | 1 | 2026-05-07 |
| 201 | 2 | 2026-04-20 |
| 202 | 2 | 2026-05-02 |
| 301 | 3 | 2026-05-04 |
| 302 | 3 | 2026-05-06 |
| 303 | 3 | 2026-05-08 |
| 304 | 3 | 2026-05-09 |
| 305 | 3 | 2026-05-10 |
※ With the window-function version, scan and sort all 50 million orders. With LATERAL, 100,000 × 3 = 300,000 rows is enough.
Expected output:
| user_id | name | order_id | ordered_at |
|---|---|---|---|
| 1 | Sato | 104 | 2026-05-07 |
| 1 | Sato | 103 | 2026-05-05 |
| 1 | Sato | 102 | 2026-05-03 |
| 2 | Suzuki | 202 | 2026-05-02 |
| 2 | Suzuki | 201 | 2026-04-20 |
| 3 | Tanaka | 305 | 2026-05-10 |
| 3 | Tanaka | 304 | 2026-05-09 |
| 3 | Tanaka | 303 | 2026-05-08 |
At most 3 rows per user. The orders read is limited to 3 entries per user.
- 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 most dangerous manifestation of the three-valued logic covered in the basic set is a subquery with NOT IN and NULL. x NOT IN (a, b, NULL) is internally x <> a AND x <> b AND x <> NULL. The final comparison is UNKNOWN, so the entire AND expression can never be TRUE—the result is always empty. The query succeeds, and the log says nothing.
-- ✗ If cancellations.user_id contains even one NULL, the result is always empty SELECT u.* FROM users u WHERE u.user_id NOT IN (SELECT user_id FROM cancellations); -- ✓ NOT EXISTS: correlated check for “no matching row”; NULL-safe SELECT u.* FROM users u WHERE NOT EXISTS ( SELECT 1 FROM cancellations c WHERE c.user_id = u.user_id); -- ✓ Anti Join: LEFT JOIN + IS NULL (often equivalent to NOT EXISTS after planner optimization) SELECT u.* FROM users u LEFT JOIN cancellations c ON c.user_id = u.user_id WHERE c.user_id IS NULL;
NOT EXISTS and LEFT JOIN ... IS NULL to an Anti Join. By contrast, NOT IN needs a NULL check for every row to remain NULL-safe, and tends to offer the optimizer fewer opportunities. Correctness and speed point in the same direction.Using a cancellation-record table cancellations (some rows may have a NULL user_id), retrieve users who have not cancelled. The query must remain correct when cancellations.user_id contains NULL and must be written in a form that can become an Anti Join. Return user_id, name (user_id ascending).
| user_id | name |
|---|---|
| 1 | Sato |
| 2 | Suzuki |
| 3 | Tanaka |
| 4 | Ito |
| 5 | Kato |
| cancel_id | user_id |
|---|---|
| 91 | 2 |
| 92 | 4 |
| 93 | NULL |
※ If you write NOT IN, the NULL on cancel_id=93 makes the result 0 rows.
Expected output (user_id ascending):
| user_id | name |
|---|---|
| 1 | Sato |
| 3 | Tanaka |
| 5 | Kato |
The cancellation record with NULL user_id is ignored as “a cancellation whose owner is unknown.” Among real users, 1, 3, and 5 have no cancellation record.
- 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
Sargable (Search ARGument ABLE) means that a WHERE condition can be used as an argument for an index scan. The rule is simple: “wrap a column in a function and the index cannot be used.” The moment you write WHERE LOWER(email) = 'a@x.com', the ordinary email index is disabled—because the index is sorted by raw email values, not by LOWERed values.
-- ✗ Wrap the column in a function → ordinary (email) index cannot be used, so Seq Scan WHERE LOWER(email) = 'user@example.com' -- ✗ Implicit type conversion also wraps the column (a CAST is inserted behind the scenes) WHERE created_at::date = '2026-05-01' WHERE phone_number = 8012345678 -- When phone_number is VARCHAR -- [OK-A] Expression index: index the expression itself (make the query expression match) CREATE INDEX idx_users_email_lower ON users (LOWER(email)); -- [OK-B] Rewrite the query so the column remains bare (for example, a half-open interval) WHERE created_at >= '2026-05-01' AND created_at < '2026-05-02'
LOWER(email) expression index works for LOWER(email) = ?, but not for email ILIKE ?—Sargability requires matching expression shapes.Implement a case-insensitive email-address search for user registration. The stored email column is VARCHAR and may contain mixed-case values. There are 1 million users and 100 searches per second. Provide both the query and the index; the search query must work for input in any casing, such as email = 'User@Example.COM'. Return user_id, email, name.
| user_id | name | |
|---|---|---|
| 1 | sato@example.com | Sato |
| 2 | Suzuki@Example.com | Suzuki |
| 3 | TANAKA@example.com | Tanaka |
| 4 | ito@example.com | Ito |
| ... | ... (1 million rows) | ... |
※ Example search: 'Suzuki@example.COM' → must return user_id=2.
Output when searching for example 'Suzuki@Example.com':
| user_id | name | |
|---|---|---|
| 2 | Suzuki@Example.com | Suzuki |
Rows read: 1 (a direct hit through an Index Scan). No full scan of 1 million rows occurs.
- 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
Writing pagination as OFFSET n LIMIT k is intuitive, but OFFSET means “read and discard.” To retrieve page 100 (OFFSET 9900), the database reads 9,900 qualifying rows, discards them, and finally returns the next 100 rows. The deeper the page, the slower it becomes linearly—this is the Deep Pagination problem.
-- ✗ OFFSET is not “skip”; it is “read and discard” SELECT * FROM articles ORDER BY created_at DESC, id DESC OFFSET 9900 LIMIT 100; -- Execution cost: read 9,900 + 100 = 10,000 rows (after ordering) -- ✓ Keyset (seek method): use the index to seek to rows below the previous page’s last key SELECT * FROM articles WHERE (created_at, id) < ('2026-05-04 10:00', 501) -- ← tuple comparison expresses “the next page” ORDER BY created_at DESC, id DESC LIMIT 100; -- Execution cost: 100 rows on every page (only seek from the index head)
(a, b) < (X, Y) is equivalent to a < X OR (a = X AND b < Y). Even when multiple rows have the same created_at, the lexicographic order of (created_at, id) determines one unique next position—this is the core of Keyset.articles has 1 million rows and a composite index (created_at DESC, id DESC). Page through it in newest-first order. If the last article on the previous page was (created_at='2026-05-04 10:00', id=501), write the query to retrieve the next 100 rows. It must not use OFFSET and must have the same performance on every page. Return id, title, created_at.
| id | title | created_at |
|---|---|---|
| 510 | New A | 2026-05-04 15:00 |
| 505 | New B | 2026-05-04 12:00 |
| 501 | Article X | 2026-05-04 10:00 |
| 499 | Article Y | 2026-05-04 10:00 |
| 498 | Article Z | 2026-05-04 09:00 |
| 490 | Older A | 2026-05-03 18:00 |
| ... | ... (1 million rows) | ... |
※ The article with id=499 has the same created_at but a smaller id than id=501, so it must also be included on the next page.
Expected output (first few rows of the next page):
| id | title | created_at |
|---|---|---|
| 499 | Article Y | 2026-05-04 10:00 |
| 498 | Article Z | 2026-05-04 09:00 |
| 490 | Older A | 2026-05-03 18:00 |
| ... | ... (up to 100 rows) | ... |
Rows read are always at most 100. Page 100 and page 10,000 have the same cost.
- 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
Composite-index design has three golden rules: (1) put equality-filtered columns first, (2) put range and sort columns next, and (3) put output-only columns in INCLUDE. When these align, the planner can construct the result from the index alone without reading the heap (the table itself)—this is Index-Only Scan. In practice, the I/O reduction can be dramatic.
-- Assumed query: “list titles for pending tasks on or after a date, newest first, 100 rows” SELECT title FROM tasks WHERE status = 'pending' AND created_at >= '2026-05-01' ORDER BY created_at DESC LIMIT 100; -- [NG-A] Reverse column order: with (created_at, status), status filtering is delayed CREATE INDEX ... ON tasks (created_at, status); -- [NG-B] Do not include title: filtering works, but the heap is visited for every row (only Index Scan) CREATE INDEX ... ON tasks (status, created_at); -- ✓ Equality → range/sort column order + INCLUDE to keep the output column in the index CREATE INDEX ... ON tasks (status, created_at DESC) INCLUDE (title);
INCLUDE column is not used as an index key (it is not a search or sort target), but its value is stored alongside the leaf entry. It is the feature for adding a column that is needed in the output but not for filtering (PostgreSQL 11+).For a huge tasks table (50 million rows, with about 1% of status values equal to 'pending'), design one composite index only that lets the following query run as an Index-Only Scan. The query cannot be changed. Aim for EXPLAIN to show Index Only Scan with Heap Fetches: 0.
SELECT title FROM tasks WHERE status = 'pending' AND created_at >= '2026-05-01' ORDER BY created_at DESC LIMIT 100;
| task_id | status | created_at | title | assignee |
|---|---|---|---|---|
| 1 | done | 2026-04-01 | ... | ... |
| 2 | pending | 2026-05-03 | Invoice review | Sato |
| 3 | pending | 2026-05-02 | Inventory count | Suzuki |
| 4 | done | 2026-05-01 | ... | ... |
| 5 | pending | 2026-05-05 | A/B test aggregation | Tanaka |
※ Evaluation points: ① column order, ② whether DESC must be specified, ③ use of INCLUDE, and ④ why title is included but not made an ORDER BY key.
Expected EXPLAIN (key points):
Limit (cost=...) (actual rows=100 loops=1)
-> Index Only Scan using idx_tasks_pending_covering on tasks
Index Cond: ((status = 'pending') AND (created_at >= '2026-05-01'))
Heap Fetches: 0 ← zero heap access
Buffers: shared hit=N read=M (index pages only)
Return 100 rows from only the small index pages, without reading the 50-million-row table heap at all.
- 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