Performance Optimization — Learn N+1, Three-Valued Logic, Half-Open Intervals, and Partial Indexes from the Basics
BasicCorrelated Subqueries / N+1Three-Valued Logic / IS DISTINCT FROMHalf-Open Date IntervalsSemi JoinPartial IndexesPostgreSQL5 questions
QUESTION 1
Correlated Subqueries and N+1 — A Subquery in SELECT Runs Once per Row
Correlated subqueryN+1Execution countJOIN + GROUP BY
Background

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;
How to spot it: If 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.
Problem

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).

Input tables
- users
user_idname
1Sato
2Suzuki
3Tanaka
4Ito
- orders (indexed on user_id)
order_iduser_idamount
10111200
1021800
10323000
1043500
1053700
1063300

※ Also count how many searches of orders occur if you write this with two correlated subqueries in the SELECT clause.

Expected Output

Expected output (user_id ascending):

user_idnameorder_cnttotal_amount
1Sato22000
2Suzuki13000
3Tanaka31500
4Ito00

The correlated-subquery version performs 100,000 users × 2 subqueries = 200,000 searches. The JOIN + GROUP BY version scans users once and orders once.

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT u.user_id, u.name, COUNT(o.order_id) AS order_cnt, COALESCE(SUM(o.amount), 0) AS total_amount 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;
LEGEND
Rows read / loaded
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.
1 / 5
user_idname
1Sato
2Suzuki
3Tanaka
4Ito
4 users rows / 6 orders rows (actual tables: 100,000 × 5,000,000)
LEARNING POINTS
Cost = cost per execution × number of executions: The total cost of a correlated subquery is the product of “cost per subquery execution × number of outer rows.” Even if an index lookup takes 0.1 ms, 100,000 rows × 2 subqueries takes 20 seconds. Before trying to “make it faster,” the first tuning step is to count “how many times it actually runs”. loops=100000 under SubPlan in EXPLAIN ANALYZE is the evidence.
The standard rewrite is “one set-based pass”: The JOIN + GROUP BY form completes with one scan of each table + one aggregation pass. The JOIN + GROUP BY syntax itself is familiar, but this question focuses on the difference in “execution count” between two forms that return the same result. The fact that multiple aggregate columns (COUNT and SUM) can share the same aggregation pass is another decisive difference from adding one subquery per column.
Correlated subqueries are not always bad: Depending on the conditions, PostgreSQL's planner can hash a subquery (a hashed SubPlan) or convert it to a Semi Join to avoid N+1. When correlation is essential, such as “the top N rows per group,” LATERAL is appropriate. The important habit is not memorizing whether a shape is good or bad, but checking the actual execution count with EXPLAIN.
ANTI-PATTERNS
Keep growing the SELECT list by adding “one subquery per column”: Each request—“we also need the count,” “the total,” or “the latest order date”—adds another subquery, causing a quiet degradation where each additional column adds another loop over all outer rows. Once you have two or more aggregate columns, consider consolidating them with JOIN + GROUP BY (or one derived table).
Fire off SQL repeatedly in an application loop (ORM N+1): Fetching a user list and then issuing 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.
Field Notes: From “thinking in rows” to “thinking in sets”
People with a procedural-language background often write SQL with a “process one row at a time” mindset, and a correlated subquery is that mindset made concrete. RDBMSs are best at transforming a set all at once. When a requirement says “for each ..., find ...,” the first set-oriented question should be: “Can I aggregate everyone in one batch and then match the results?” The execution-count trap also hides in the WHERE clause, but another frequent pitfall is NULL's three-valued logic, where rows whose comparison is not TRUE silently disappear. We will examine it in Q2.
QUESTION 2
NULL and Three-Valued Logic — A “Not Equal” Comparison Silently Drops NULLs
NULLThree-valued logicIS DISTINCT FROMCorrectness
Background

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 core idea: NULL is a marker for “unknown.” Comparing an “unknown value” also produces “unknown (UNKNOWN),” and adding NOT does not change UNKNOWN into anything else. Most incidents where an exclusion condition unexpectedly reduces the row count start here.
Problem

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).

Input table
- orders (status allows NULL)
order_idstatusamount
1paid1200
2cancelled3000
3NULL800
4shipped500
5cancelled700
6NULL2000

※ Before looking at the answer, predict how many rows WHERE status <> 'cancelled' returns.

Expected Output

Expected output (order_id ascending):

order_idstatusamount
1paid1200
3NULL800
4shipped500
6NULL2000

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.

Model Answer
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)
*/
Explanation (table transitions & key points)
SELECT order_id, status, amount FROM orders WHERE status IS DISTINCT FROM 'cancelled' ORDER BY order_id;
LEGEND
Rows read / loaded
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.
1 / 5
order_idstatusamount
1paid1200
2cancelled3000
3NULL800
4shipped500
5cancelled700
6NULL2000
6 rows (2 of them are NULL)
LEARNING POINTS
WHERE is a filter that passes “TRUE only”: The practical effect of three-valued logic is that UNKNOWN falls on the same non-passing side as FALSE. With a positive condition (= '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.
IS DISTINCT FROM is a “NULL-safe not-equal”: 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.
Performance perspective — exclusion conditions are not where indexes shine: An exclusion condition such as <> '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.
ANTI-PATTERNS
Wrap the column in COALESCE to absorb NULL: 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.
Leave NULL undefined and fight only in the query: If status is truly required, the proper solution is a 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.
Field Notes: Three-valued NULL logic affects every clause
Three-valued logic affects more than WHERE. It affects the JOIN ON clause (NULL keys do not join), aggregates (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.
QUESTION 3
Making Date Conditions Sargable — Filter with Half-Open Intervals Instead of Functions
Date rangesHalf-open intervalsSargableRange Scan
Background

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'
How to remember [start, end): “Include the start, exclude the end.” Put the first day of the next month or midnight of the next day at the end, and you never have to think about fractions such as 23:59:59.999…; adjacent periods connect without gaps.
Problem

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.

Input table
- orders (ordered_at is timestamp / indexed)
order_idordered_atamount
1012026-04-30 23:50900
1022026-05-01 00:001200
1032026-05-14 12:30800
1042026-05-31 18:453000
1052026-06-01 00:101500
1062026-05-08 09:15500

※ 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

Expected output:

order_cnttotal_amount
45500

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.

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders WHERE ordered_at >= DATE '2026-05-01' AND ordered_at < DATE '2026-06-01';
LEGEND
Rows read / loaded
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.
1 / 5
order_idordered_atamount
1012026-04-30 23:50900
1022026-05-01 00:001200
1062026-05-08 09:15500
1032026-05-14 12:30800
1042026-05-31 18:453000
1052026-06-01 00:101500
6 rows shown in time order (actual table: 8 million rows)
LEARNING POINTS
A “period” is a continuous interval, not the result of a function: Rows in an index are ordered by time, so periods such as a month, day, or year are always one contiguous interval. The rewrite is therefore mechanical: for a month, [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.
BETWEEN causes timestamp incidents: BETWEEN is a closed interval that includes both endpoints. It is safe for date-to-date comparisons, but when '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.
If you truly need a function, use an expression index: If you know that all aggregation will be monthly, you can use an expression index such as 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.
ANTI-PATTERNS
Compare dates with a ::date cast: 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'.
Convert to strings with TO_CHAR and compare: 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.
Field Notes: The meaning of “May” shifts when time zones are involved
When the column is 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.
QUESTION 4
JOIN Fan-Out and DISTINCT — Prevent Duplicates Instead of Removing Them
DISTINCTFan-outEXISTSSemi Join
Background

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)
Decision rule: If you see a JOIN where not a single column from the right table appears in SELECT, be careful. Its purpose is probably “check existence,” and there was never a need to create joined rows.
Problem

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).

Input tables
- users
user_idname
1Sato
2Suzuki
3Tanaka
4Ito
- orders (indexed on user_id)
order_iduser_idamount
10111200
1021800
10313000
1042500
1052700
1063300

※ Imagine how many rows JOIN + DISTINCT would create and then reduce with real data averaging 50 orders per customer.

Expected Output

Expected output (user_id ascending):

user_idname
1Sato
2Suzuki
3Tanaka

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.

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT u.user_id, u.name FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id ) ORDER BY u.user_id;
LEGEND
Rows read / loaded
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.
1 / 5
order_iduser_idamount
10111200
1021800
10313000
1042500
1052700
1063300
4 users rows / 6 orders rows (actual tables: 100,000 × 5,000,000)
LEARNING POINTS
How to detect fan-out: If the row count after a JOIN exceeds the row count of the left table, one-to-many duplication is happening. It causes not only duplicate rows but also the accident of inflated COUNT or SUM when you aggregate after the JOIN—for example, joining order details can multiply sales by the number of details. Build the habit of estimating multiplicity from the join keys by asking “Will this JOIN increase the number of rows?”
A Semi Join declares that “one match is enough”: With EXISTS, the planner can choose a Semi Join that stops the inner search at the first match. With JOIN + DISTINCT, it must assume that all joined rows might be needed and cannot stop early. Even with the same result, expressing the intent precisely produces a better plan—SQL is a declarative language for saying what you want. Think of it as the existence-side counterpart to the familiar NOT EXISTS (Anti Join).
DISTINCT is legitimate in some situations: If you want a list of values that may naturally contain duplicates in the source—for example, a list of prefectures with shipping history—DISTINCT is the right approach. The problem is using it to remove duplicates created as a JOIN side effect without understanding their cause. When your hand reaches for DISTINCT, first articulate “where did these duplicates come from?”
ANTI-PATTERNS
Keep “SELECT DISTINCT for now” permanently: Covering unexplained duplicates with DISTINCT may fix the immediate output, but when another JOIN is added later, combinations of duplicates explode multiplicatively and the query suddenly becomes slow. A state where nobody knows what is happening under the cover is the most dangerous.
Keep correcting fan-out with COUNT(DISTINCT ...): Recounting a fan-out result with 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.
Field Notes: Distinguish “row-increasing JOINs” from “filtering JOINs”
JOINs have two broad roles: a JOIN that adds columns (one-to-one or many-to-one, such as adding a customer name to an order) and a JOIN that can add rows (one-to-many, such as attaching order details to a customer). The former is safe; the latter affects aggregation, counts, and duplicates. During review, always check the multiplicity of the join key. If a row-increasing JOIN is for an existence check, move to EXISTS; if it is for aggregation, pre-aggregate before the JOIN. This classification alone prevents most duplicate-related bugs. In Q5, we will learn a tool for speeding up searches on a “skewed column”: the partial index.
QUESTION 5
Partial Indexes — On a Skewed Column, Index Only the Minority
Partial indexSelectivityCREATE INDEXIndex Scan
Background

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';
When it is usable: The query's WHERE clause must imply the index's WHERE condition (the planner must be able to prove that every row satisfying the query condition is in the index). The moment the conditions diverge, the index is removed from consideration.
Problem

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.

Input table
- tasks (status is extremely skewed toward 'done')
task_idtitlestatuscreated_at
1Create reportdone2026-05-01 09:00
2Check invoicepending2026-05-03 10:00
3Fix bugdone2026-05-05 11:00
4Respond to quotedone2026-05-06 15:00
5Take inventorypending2026-05-02 14:00
6Reply to emaildone2026-05-07 08:30
7Renew contractin_progress2026-05-04 13:00
8Migrate datadone2026-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

Expected output (created_at ascending, up to 20 rows):

task_idtitlecreated_at
5Take inventory2026-05-02 14:00
2Check invoice2026-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.

Model Answer
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
  */
Explanation (table transitions & key points)
CREATE INDEX idx_tasks_pending ON tasks (created_at) WHERE status = 'pending'; SELECT task_id, title, created_at FROM tasks WHERE status = 'pending' ORDER BY created_at LIMIT 20;
LEGEND
Rows read / loaded
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.
1 / 5
task_idtitlestatuscreated_at
1Create reportdone2026-05-01 09:00
2Check invoicepending2026-05-03 10:00
3Fix bugdone2026-05-05 11:00
4Respond to quotedone2026-05-06 15:00
5Take inventorypending2026-05-02 14:00
6Reply to emaildone2026-05-07 08:30
7Renew contractin_progress2026-05-04 13:00
8Migrate datadone2026-05-08 16:20
8 rows (actual table: 1 million rows, about 5,000 pending)
LEARNING POINTS
A partial index benefits “read, write, and memory”: Because there are only minority entries, the B-tree is shallow and reads are fast; updates to majority rows do not propagate to the index, making writes lighter; and the small size makes it easier to keep in cache. Its self-cleaning behavior—an entry leaves when status becomes 'done'—also fits growing queue tables extremely well.
Choose the key to eliminate ORDER BY too: This question's index uses created_at as the key instead of status. The WHERE clause (the definition condition) handles filtering, so the key can be assigned to the column used for ordering or range conditions. As a result, 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.
A UNIQUE partial index is also a powerful constraint: With 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.
ANTI-PATTERNS
Put a normal index on a skewed column and call it done: An index on all of (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.
Let the index condition drift away from the application's query: If the requirement later changes to show both pending and retry, and the query becomes 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.
Field Notes: Summary of Basic Set 3 — Five principles for “correct, fast” SELECTs
Q1–Q5 can be summarized as follows: 1. Judge subqueries by “how many times they run” (rewrite N+1 as JOIN + aggregation); 2. Suspect three-valued logic for negative conditions on nullable columns (IS DISTINCT FROM / OR IS NULL); 3. Do not cut dates with functions; filter with a half-open interval [start, next start); 4. Prevent duplicates with a Semi Join (EXISTS) instead of removing them with DISTINCT; 5. On skewed columns, create a partial index for only the minority. Combined with earlier principles (Sargability, prefix LIKE, splitting OR, WHERE versus HAVING, covering indexes, and so on), the overall tuning map has three axes: write queries in a form that can use an index, reduce execution count and intermediate-result size, and protect correctness around NULLs and boundaries. In the sequel, we will verify these ideas against actual EXPLAIN output and explore “the planner's perspective,” including join-algorithm selection and statistics freshness.