Performance Optimization — Learn Practical INDEX Design, LATERAL, and Top-N in Practice
AdvancedComposite INDEX DesignLATERAL JOINTop-N per GroupFILTER ClausePostgreSQL-compatible5 questions
QUESTION 1
Date Half-Open Intervals — Cut Through Function and BETWEEN Traps with Range Conditions
Half-Open Intervaldate_truncBETWEENRange Scan
Background

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'
Why half-open intervals are correct: With the end set to < the start of the next period, you never need to reason about fractional seconds, and you need no special cases for 28-, 30-, or 31-day months. The same form works for days, months, and years, making it the universal form for period conditions.
Problem

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.

Source table
- orders (ordered_at is timestamp / indexed)
order_idordered_atamount
12026-04-30 23:59:59700
22026-05-01 00:00:003400
32026-05-14 12:30:00800
42026-05-20 18:05:00950
52026-05-31 23:59:59.92600
62026-06-01 00:00:001500

※ 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

Expected output (ordered_at ascending):

order_idordered_atamount
22026-05-01 00:00:003400
32026-05-14 12:30:00800
42026-05-20 18:05:00950
52026-05-31 23:59:59.92600

With a half-open interval, only the contiguous index range is read, and the row right at month-end (id=5) is included exactly.

QUESTION 2
Composite Index Column Order — Design for “Equality → Range → Sort”
Composite indexColumn orderEquality → rangeAvoiding sort
Background

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
What about the reverse order (shipped_at, status)? With the range column first, you must read a wide interval containing every status and judge status row by row. “Put columns narrowed by equality on the left; range and sort columns on the right.” This is the most important composite-index design principle.
Problem

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.

Source table
- shipments (composite index on (status, shipped_at))
ship_idstatusshipped_atdest
1pending2026-06-02Tokyo
2shipped2026-05-28Osaka
3shipped2026-06-01Tokyo
4delivered2026-06-03Nagoya
5shipped2026-06-04Sapporo
6pending2026-05-30Fukuoka
7shipped2026-06-09Tokyo
8delivered2026-06-05Kobe

※ Also consider how the scan would change if the index were reversed to (shipped_at, status).

Expected Output

Expected output (shipped_at ascending):

ship_idstatusshipped_atdest
3shipped2026-06-01Tokyo
5shipped2026-06-04Sapporo
7shipped2026-06-09Tokyo

Equality + range + ORDER BY all use one index, so the Sort node disappears from the execution plan.

QUESTION 3
LATERAL JOIN — Fetch the Latest 2 Orders per Customer Directly from the Index
LATERALTop-N per GroupLIMITIndex Scan × N
Background

“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
Where the speed comes from: With a composite index (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.
Problem

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.

Source tables
- customers
customer_idname
101Sato
102Suzuki
103Tanaka
- orders (composite index on (customer_id, ordered_at DESC))
order_idcustomer_idordered_atamount
11012026-06-011200
21022026-06-02800
31012026-06-033000
41032026-06-04500
51012026-06-05900
61022026-06-061500
71032026-06-07700
81012026-06-082200
91022026-06-09600

※ 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

Expected output (customer_id ascending, ordered_at descending):

customer_idnameorder_idordered_atamount
101Sato82026-06-082200
101Sato52026-06-05900
102Suzuki92026-06-09600
102Suzuki62026-06-061500
103Tanaka72026-06-07700
103Tanaka42026-06-04500
QUESTION 4
FILTER Clause — Fold Multiple Conditional Aggregations into One Scan
FILTERConditional aggregationHAVINGOne-pass aggregation
Background

“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
Distinguish the three layers WHERE / FILTER / HAVING: WHERE narrows the rows entering all aggregates (before aggregation; an index can help). FILTER narrows the rows for one aggregate function only. HAVING narrows groups after aggregation. This adds a third, middle layer to the basic set’s Q4 distinction between WHERE and HAVING.
Problem

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.

Source table
- payments
pay_idmethodstatusamount
1cardok1200
2bankok5000
3cardng800
4cardok2400
5paypayok600
6bankng3000
7cardng500
8bankok7000

※ 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

Expected output (method ascending):

methodcntok_cntng_amount
bank323000
card421300

The table is scanned once. Three aggregates run alongside one another in the same pass.

QUESTION 5
Partial Indexes — Index Only the “Unprocessed 1%” for a Small, Fast Structure
Partial indexIndex with WHERELIMITQueue processing
Background

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
When it is used: A partial index is chosen only when the query’s WHERE implies the index predicate. Writing 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.
Problem

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.

Source table
- tasks (about 1% pending out of 1 million rows)
task_idstatuscreated_attitle
1done2026-05-01Create invoice
2pending2026-05-03Inventory count
3done2026-05-04Monthly report
4pending2026-05-06Contract review
5canceled2026-05-08Legacy site update
6pending2026-05-10Prepare audit materials
7done2026-05-12Schedule interviews
8pending2026-05-13Price 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

Expected output (created_at ascending, 3 rows):

task_idcreated_attitle
22026-05-03Inventory count
42026-05-06Contract review
62026-05-10Prepare audit materials

The index contains only about 10,000 pending rows, and LIMIT ends the scan after its first three entries.