A correlated subquery in the SELECT clause (a subquery that references a value from the outer row) runs once for each row in the outer result. If the outer result has 100,000 rows and there are two subqueries, the inner table is searched 200,000 times—this is the SQL version of the N+1 problem. Even if each individual search is fast with an index, you cannot escape the multiplication of “number of executions × cost per execution.”
-- ✗ Search orders once per users row (and there are two subqueries) SELECT u.name, (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.user_id), (SELECT SUM(amount) FROM orders o WHERE o.user_id = u.user_id) FROM users u; -- ✓ JOIN + GROUP BY: scan each table once, then aggregate in one pass SELECT u.name, COUNT(o.order_id), COALESCE(SUM(o.amount), 0) FROM users u LEFT JOIN orders o ON o.user_id = u.user_id GROUP BY u.user_id, u.name;
EXPLAIN ANALYZE shows a SubPlan with loops=N (where N is the number of outer rows), you have N+1. The query may be “one statement,” but its execution is a loop.From users (100,000 actual rows) and orders (5 million actual rows), retrieve the order count and total amount for each user. Write it so that scans of orders do not grow in proportion to the number of users (each table is scanned once). Include users with no orders with count 0 and total 0. The output columns must be user_id, name, order_cnt, total_amount (user_id ascending).
| user_id | name |
|---|---|
| 1 | Sato |
| 2 | Suzuki |
| 3 | Tanaka |
| 4 | Ito |
| order_id | user_id | amount |
|---|---|---|
| 101 | 1 | 1200 |
| 102 | 1 | 800 |
| 103 | 2 | 3000 |
| 104 | 3 | 500 |
| 105 | 3 | 700 |
| 106 | 3 | 300 |
※ Also count how many searches of orders occur if you write this with two correlated subqueries in the SELECT clause.
Expected output (user_id ascending):
| user_id | name | order_cnt | total_amount |
|---|---|---|---|
| 1 | Sato | 2 | 2000 |
| 2 | Suzuki | 1 | 3000 |
| 3 | Tanaka | 3 | 1500 |
| 4 | Ito | 0 | 0 |
The correlated-subquery version performs 100,000 users × 2 subqueries = 200,000 searches. The JOIN + GROUP BY version scans users once and orders once.
SELECT u.user_id, u.name, COUNT(o.order_id) AS order_cnt, -- NULLs are not counted → 0 for users with no orders COALESCE(SUM(o.amount), 0) AS total_amount -- SUM of all NULL rows is NULL → convert it to 0 FROM users u LEFT JOIN orders o ON o.user_id = u.user_id GROUP BY u.user_id, u.name ORDER BY u.user_id; /* Execution order: 1. FROM/LEFT JOIN → join users and orders (retain users with no orders) 2. GROUP BY → group by user_id and name 3. Aggregation → calculate COUNT / SUM in one pass 4. SELECT → use COALESCE to replace SUM's NULL with 0 5. ORDER BY → sort by user_id ascending */
LEGEND
1. Target table — users (the outer side)
FROM users uWe want an aggregate result with “one row per user.” The key point is the number of rows on the outer users side. The number of correlated-subquery executions is directly proportional to it.| user_id | name |
|---|---|
| 1 | Sato |
| 2 | Suzuki |
| 3 | Tanaka |
| 4 | Ito |
loops=100000 under SubPlan in EXPLAIN ANALYZE is the evidence.LATERAL is appropriate. The important habit is not memorizing whether a shape is good or bad, but checking the actual execution count with EXPLAIN.SELECT SUM(...) once per user inside the screen's loop is the same bad shape as this question, made even slower by network round trips. SQL N+1 and application N+1 have the same root cause—the principle of “one batched pass” works at every layer.SQL comparison results are not two-valued TRUE / FALSE; they are three-valued TRUE / FALSE / UNKNOWN. When NULL is involved, both = and <> produce UNKNOWN, and a WHERE clause passes only TRUE rows. In other words, if you write status <> 'cancelled', rows whose status is NULL disappear from the result without an error or warning.
-- ✗ NULL <> 'cancelled' is UNKNOWN → it cannot pass WHERE and silently disappears WHERE status <> 'cancelled' -- ✓ Compare NULL as “one value” → the result is always TRUE/FALSE WHERE status IS DISTINCT FROM 'cancelled' -- ✓ Traditional form for compatibility (same meaning) WHERE (status <> 'cancelled' OR status IS NULL)
The status column in orders allows NULL (orders migrated from a legacy system have an unset status = NULL). Retrieve all orders other than cancelled orders. Include orders whose status is NULL as “not cancelled”. The output columns must be order_id, status, amount (order_id ascending).
| order_id | status | amount |
|---|---|---|
| 1 | paid | 1200 |
| 2 | cancelled | 3000 |
| 3 | NULL | 800 |
| 4 | shipped | 500 |
| 5 | cancelled | 700 |
| 6 | NULL | 2000 |
※ Before looking at the answer, predict how many rows WHERE status <> 'cancelled' returns.
Expected output (order_id ascending):
| order_id | status | amount |
|---|---|---|
| 1 | paid | 1200 |
| 3 | NULL | 800 |
| 4 | shipped | 500 |
| 6 | NULL | 2000 |
With only <>, the two NULL rows (ids 3 and 6) silently disappear, leaving only 2 rows. In a sales aggregate, that would omit 2800 yen.
SELECT order_id, status, amount FROM orders WHERE status IS DISTINCT FROM 'cancelled' -- NULL is also judged to be “different from cancelled” ORDER BY order_id; -- Alternative (traditional form that works on older DBMSs): WHERE (status <> 'cancelled' OR status IS NULL) /* Execution order: 1. FROM: scan orders 2. WHERE: evaluate status IS DISTINCT FROM 'cancelled' for each row → NULL is treated as a “value” in a two-valued comparison, so the result is always TRUE / FALSE (never UNKNOWN) → NULL rows also become TRUE and pass 3. SELECT: finalize the three columns 4. ORDER BY: sort by order_id ascending → Result: 4 rows × 3 columns (nothing is missed) */
LEGEND
1. Target table — status contains NULLs
FROM ordersThe status for ids 3 and 6 is NULL (unset). In practice, it is perfectly normal for NULL to exist in a nullable column, for example in rows migrated from a legacy system or created before a field was added. The trap appears as soon as this premise is forgotten.| order_id | status | amount |
|---|---|---|
| 1 | paid | 1200 |
| 2 | cancelled | 3000 |
| 3 | NULL | 800 |
| 4 | shipped | 500 |
| 5 | cancelled | 700 |
| 6 | NULL | 2000 |
= 'paid'), it feels intuitive that NULL rows are excluded. With negative or exclusion conditions (<> / NOT LIKE / NOT IN), even rows other than the value you wanted to exclude disappear. The familiar “NOT IN and NULL” trap concerned NULL on the subquery side; this question concerns NULL on the column side—another face of the same three-valued logic.a IS DISTINCT FROM b treats NULL as a value and always returns TRUE/FALSE (NULL IS DISTINCT FROM 'x' is TRUE, and NULL IS NOT DISTINCT FROM NULL is also TRUE). Its advantage is that the intent is readable in one clause. On DBMSs or older versions that do not support it, use the traditional form (a <> b OR a IS NULL). Adapt to your team's SQL dialect, but always make explicit which side should include NULL.<> 'cancelled' usually matches almost every row (high selectivity), so the planner considers a Seq Scan more reasonable than an index. The main issue here is therefore correctness, not speed. Conversely, extracting only “cancelled” rows is a minority-value lookup, where indexes shine—directly leading to Q5's partial index.COALESCE(status, '') <> 'cancelled' may look correct, but it violates Sargability by wrapping the column in a function (the same pattern as Q1 in the previous set) and bakes the implicit rule “empty string and NULL are the same” into the query. Make the comparison's meaning explicit with IS DISTINCT FROM or IS NULL.NOT NULL constraint + DEFAULT in the schema. If NULL is allowed, document its business meaning—unset? unknown? not applicable?—as part of the specification, or developers will interpret it differently and aggregates will continue to vary.COUNT(col) does not count NULL and AVG excludes it from the denominator), CHECK constraints (UNKNOWN passes), and the familiar NOT IN. When you work with a new table, make a habit of using d table_name first to check whether NOT NULL constraints exist; this catches such incidents during design. In Q3, we will handle another frequent area where correctness and speed meet: date and time range conditions.If you write “orders from May 2026” with DATE_TRUNC or EXTRACT, the column is wrapped in a function and the index cannot be used (the function must be evaluated for every row before comparison). A period is really a continuous interval on the time axis, so rewriting it as the half-open interval >= start AND < end lets a B-tree Range Scan work directly. In addition, BETWEEN on a timestamp column has a correctness trap that misses data on the last day.
-- ✗ Wrap the column in a function → evaluate every row (no index) WHERE DATE_TRUNC('month', ordered_at) = DATE '2026-05-01' -- ✗ BETWEEN's upper bound is '2026-05-31 00:00:00' → daytime data on 5/31 disappears WHERE ordered_at BETWEEN '2026-05-01' AND '2026-05-31' -- ✓ Half-open interval [5/1, 6/1): the index works and nothing is missed WHERE ordered_at >= DATE '2026-05-01' AND ordered_at < DATE '2026-06-01'
The ordered_at column in orders (8 million actual rows) is a timestamp with an index named idx_orders_ordered_at. Aggregate the number of orders and total amount for May 2026 in a form that enables an Index Range Scan and does not miss end-of-month data. The output columns must be order_cnt, total_amount.
| order_id | ordered_at | amount |
|---|---|---|
| 101 | 2026-04-30 23:50 | 900 |
| 102 | 2026-05-01 00:00 | 1200 |
| 103 | 2026-05-14 12:30 | 800 |
| 104 | 2026-05-31 18:45 | 3000 |
| 105 | 2026-06-01 00:10 | 1500 |
| 106 | 2026-05-08 09:15 | 500 |
※ Pay special attention to order_id=104 (18:45 on 5/31). What happens to it if you write BETWEEN '2026-05-01' AND '2026-05-31'?
Expected output:
| order_cnt | total_amount |
|---|---|
| 4 | 5500 |
The matching ids are 102, 103, 104, and 106: 4 orders. The BETWEEN version drops 104 and produces 3 orders / 2500 yen, silently corrupting the monthly report.
SELECT COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders WHERE ordered_at >= DATE '2026-05-01' -- include the start AND ordered_at < DATE '2026-06-01'; -- exclude the end (half-open interval) /* Execution order: 1. Range condition in WHERE → choose idx_orders_ordered_at 2. Binary search the B-tree → jump to the start of the interval 3. Scan continuously in time order → stop at the end of the interval 4. COUNT / SUM only within the interval → aggregate in one pass */
LEGEND
1. Target table — the index is ordered by time
FROM orders (idx_orders_ordered_at)Rows in idx_orders_ordered_at form a continuous sequence ordered by time. On this sequence, “May 2026” is one contiguous interval—the physical basis for rewriting the condition as a half-open interval.| order_id | ordered_at | amount |
|---|---|---|
| 101 | 2026-04-30 23:50 | 900 |
| 102 | 2026-05-01 00:00 | 1200 |
| 106 | 2026-05-08 09:15 | 500 |
| 103 | 2026-05-14 12:30 | 800 |
| 104 | 2026-05-31 18:45 | 3000 |
| 105 | 2026-06-01 00:10 | 1500 |
[first day of this month, first day of next month); for a day, [midnight today, midnight tomorrow). Calculating the end as start + INTERVAL '1 month' means you never need to care whether the month has 28 or 31 days.'2026-05-31' is supplied for a timestamp column, the upper bound is midnight on the last day, dropping almost all data from that day. Adding '23:59:59' is only a patch and still leaves gaps below millisecond precision. Standardizing on half-open intervals makes the entire debate disappear.CREATE INDEX ON orders (DATE_TRUNC('month', ordered_at)). However, the same plain index with half-open ranges supports months, days, and arbitrary periods, so range conditions are more general. An expression index is an option when there is evidence that one specific query shape overwhelmingly dominates.WHERE ordered_at::date = '2026-05-14' looks convenient, but a cast on the column is the same sin as the implicit cast in Q1 of the previous set, and the index dies. The correct form is a one-day half-open interval: ordered_at >= '2026-05-14' AND ordered_at < '2026-05-15'.TO_CHAR(ordered_at, 'YYYY-MM') = '2026-05' is a double loss: “wrap the column in a function + compare strings,” throwing away the index, statistics, and range comparisons. Formatting for display (TO_CHAR) and filtering are different jobs—format in SELECT, filter on the raw column.timestamptz (with a time zone), the instant represented by DATE '2026-05-01' depends on the session time zone. Even for a service in Japan, if the server is configured for UTC, a nine-hour shift in the “May sales” aggregate is a classic incident. In reporting SQL, either make the reference time zone explicit, as in ordered_at >= TIMESTAMPTZ '2026-05-01 00:00:00+09', or establish a team convention for SET timezone. Q4 will dissect duplicate rows caused by JOIN increasing the row count, and the cost of covering them with DISTINCT.A one-to-many JOIN duplicates each row in the left table once for every matching row on the right (fan-out). If you write “list customers who have placed an order” with a JOIN, each customer is duplicated by their order count and then those rows are collapsed with DISTINCT—in other words, you create a large number of duplicates and then pay to remove them. If all you need is an existence check, EXISTS (a Semi Join) is correct: stop searching as soon as the first match is found and never create the duplicates.
-- ✗ Inflate rows with JOIN, then cover it with DISTINCT SELECT DISTINCT u.user_id, u.name FROM users u JOIN orders o ON o.user_id = u.user_id -- ✓ EXISTS: tell the DB that finding one match is enough (Semi Join) SELECT u.user_id, u.name FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.user_id)
From users (100,000 actual rows) and orders (5 million actual rows, indexed on user_id), retrieve the list of customers who have placed at least one order. Write it so that duplicate rows are never created (do not use DISTINCT). The output columns must be user_id, name (user_id ascending).
| user_id | name |
|---|---|
| 1 | Sato |
| 2 | Suzuki |
| 3 | Tanaka |
| 4 | Ito |
| order_id | user_id | amount |
|---|---|---|
| 101 | 1 | 1200 |
| 102 | 1 | 800 |
| 103 | 1 | 3000 |
| 104 | 2 | 500 |
| 105 | 2 | 700 |
| 106 | 3 | 300 |
※ Imagine how many rows JOIN + DISTINCT would create and then reduce with real data averaging 50 orders per customer.
Expected output (user_id ascending):
| user_id | name |
|---|---|
| 1 | Sato |
| 2 | Suzuki |
| 3 | Tanaka |
Ito, who has no orders, is excluded. The EXISTS version creates no duplicate rows at all and stops searching orders at the first match for each customer.
SELECT u.user_id, u.name FROM users u WHERE EXISTS ( SELECT 1 -- only the existence truth value is needed (the value is unused) FROM orders o WHERE o.user_id = u.user_id ) ORDER BY u.user_id; /* Execution order: 1. FROM users → scan the outer table 2. WHERE EXISTS → stop after one hit through the index (Semi Join) 3. Only TRUE users pass → no joined rows are created, so there are no duplicates 4. SELECT / ORDER BY → finalize two columns in user_id ascending order */
LEGEND
1. Target tables — customers and orders are one-to-many
users (4 rows) × orders (6 rows)Sato has 3 orders, Suzuki has 2, Tanaka has 1, and Ito has 0: a skewed one-to-many relationship. We want the three customers with orders; none of the order details (amount or count) is needed.| order_id | user_id | amount |
|---|---|---|
| 101 | 1 | 1200 |
| 102 | 1 | 800 |
| 103 | 1 | 3000 |
| 104 | 2 | 500 |
| 105 | 2 | 700 |
| 106 | 3 | 300 |
COUNT(DISTINCT u.user_id) pays for the expansion and then adds an expensive DISTINCT aggregate—a double charge. If the goal is aggregation, aggregate before the JOIN (pre-aggregation), or separate existence checks with EXISTS—cut off the source of the duplicates.For a column with an extremely skewed distribution, such as status with 99% 'done' and 0.5% 'pending', a normal index on every row is dead weight: most of its entries are never read. A partial index uses CREATE INDEX ... WHERE condition to index only rows that satisfy the condition. Its size drops dramatically, and write cost is limited to the target rows.
-- Normal index: entries for 1 million rows (99% are unused 'done') CREATE INDEX idx_tasks_status ON tasks (status); -- Partial index: only about 5,000 'pending' rows, ordered by created_at CREATE INDEX idx_tasks_pending ON tasks (created_at) WHERE status = 'pending';
In the tasks table (1 million actual rows, 99% are 'done' and about 0.5% are 'pending'), a screen that displays 20 unprocessed tasks in oldest-created-first order is called frequently. Create a partial index dedicated to this screen and write a SELECT that can use it. The output columns must be task_id, title, created_at.
| task_id | title | status | created_at |
|---|---|---|---|
| 1 | Create report | done | 2026-05-01 09:00 |
| 2 | Check invoice | pending | 2026-05-03 10:00 |
| 3 | Fix bug | done | 2026-05-05 11:00 |
| 4 | Respond to quote | done | 2026-05-06 15:00 |
| 5 | Take inventory | pending | 2026-05-02 14:00 |
| 6 | Reply to email | done | 2026-05-07 08:30 |
| 7 | Renew contract | in_progress | 2026-05-04 13:00 |
| 8 | Migrate data | done | 2026-05-08 16:20 |
※ The key question is which column to use as the key so the index can also eliminate the ORDER BY created_at sort.
Expected output (created_at ascending, up to 20 rows):
| task_id | title | created_at |
|---|---|---|
| 5 | Take inventory | 2026-05-02 14:00 |
| 2 | Check invoice | 2026-05-03 10:00 |
Even with a 1-million-row table, the query reads only the first up to 20 entries of a small index containing pending tasks in created_at order.
CREATE INDEX idx_tasks_pending ON tasks (created_at) -- key = the ordering column (eliminate the sort) WHERE status = 'pending'; -- index only the minority 'pending' rows SELECT task_id, title, created_at FROM tasks WHERE status = 'pending' -- same as the index WHERE condition → the partial index can be used ORDER BY created_at LIMIT 20; /* Execution order: 1. Check implication between WHERE and the index condition → adopt the partial index 2. Read idx_tasks_pending from the beginning → already ordered by created_at ascending 3. Fetch columns from the heap and stop at LIMIT → no sort stage */
LEGEND
1. Target table — the status distribution is extremely skewed
FROM tasks (99% done)Completed tasks keep growing while unprocessed tasks remain a tiny minority—the classic shape of a queue table. The screen always needs only the minority 'pending' rows. There is no reason to pay index capacity for the majority.| task_id | title | status | created_at |
|---|---|---|---|
| 1 | Create report | done | 2026-05-01 09:00 |
| 2 | Check invoice | pending | 2026-05-03 10:00 |
| 3 | Fix bug | done | 2026-05-05 11:00 |
| 4 | Respond to quote | done | 2026-05-06 15:00 |
| 5 | Take inventory | pending | 2026-05-02 14:00 |
| 6 | Reply to email | done | 2026-05-07 08:30 |
| 7 | Renew contract | in_progress | 2026-05-04 13:00 |
| 8 | Migrate data | done | 2026-05-08 16:20 |
ORDER BY created_at LIMIT 20 completes by simply reading 20 entries from the index start; the familiar principle of “eliminate the sort stage” also works with partial indexes.CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL, you can express a conditional uniqueness rule such as “an email address is unique only among rows that have not been logically deleted.” A partial index is both a performance tool and a schema-design tool for business rules that a normal UNIQUE constraint cannot express.(status) may not be selected by the planner when searching for the majority 'done' rows because selectivity is too low (a Seq Scan is cheaper), yet you still maintain 1 million entries just to support minority searches. Completion means checking not only that “an index was created,” but also that EXPLAIN uses it and its size is justified.status IN ('pending', 'retry'), the implication no longer holds and the partial index silently stops being used. Manage the partial-index definition and query as a design pair, and make an EXPLAIN regression check part of every condition change. Also note that the planner cannot prove implication for parameterized conditions such as status = $1.