Performance Optimization — Learn LATERAL Top-N, Expression Indexes, and Composite-Index Column Order in Practice
AdvancedLATERAL Top-NNOT IN and NULLExpression indexesComposite-index column order / INCLUDEIndex-Only ScanPostgreSQL-compatible5 questions
QUESTION 1
Top-N per Group — Eliminate “rows × sorting” with a LATERAL JOIN
LATERALTop-N per groupWindow functionIndex compatibility
Background

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;
How to choose: When there are few users and orders is huge, use LATERAL (three rows per user from the head of the index); when a full scan is already needed for aggregation, use a window function. “Correlated = always bad” is false: evaluate the combination of the index’s physical layout and LIMIT.
Problem

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.

Source tables
- users
user_idname
1Sato
2Suzuki
3Tanaka
- orders (index: user_id, ordered_at DESC)
order_iduser_idordered_at
10112026-05-01
10212026-05-03
10312026-05-05
10412026-05-07
20122026-04-20
20222026-05-02
30132026-05-04
30232026-05-06
30332026-05-08
30432026-05-09
30532026-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

Expected output:

user_idnameorder_idordered_at
1Sato1042026-05-07
1Sato1032026-05-05
1Sato1022026-05-03
2Suzuki2022026-05-02
2Suzuki2012026-04-20
3Tanaka3052026-05-10
3Tanaka3042026-05-09
3Tanaka3032026-05-08

At most 3 rows per user. The orders read is limited to 3 entries per user.

QUESTION 2
The NOT IN and NULL Trap — One row in a subquery can erase every result
NOT INNULLNOT EXISTSAnti Join
Background

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;
Performance too: PostgreSQL converts 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.
Problem

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

Source tables
- users
user_idname
1Sato
2Suzuki
3Tanaka
4Ito
5Kato
- cancellations
cancel_iduser_id
912
924
93NULL

※ If you write NOT IN, the NULL on cancel_id=93 makes the result 0 rows.

Expected Output

Expected output (user_id ascending):

user_idname
1Sato
3Tanaka
5Kato

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.

QUESTION 3
Sargability and Expression Indexes — Keep functions on columns from slowing searches
SargableExpression indexFunctional indexIndex Scan
Background

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'
How to judge: The query’s WHERE expression and the index expression must be literally the same shape for the planner to use the index. The LOWER(email) expression index works for LOWER(email) = ?, but not for email ILIKE ?Sargability requires matching expression shapes.
Problem

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.

Source table
- users (1 million rows, index under consideration)
user_idemailname
1sato@example.comSato
2Suzuki@Example.comSuzuki
3TANAKA@example.comTanaka
4ito@example.comIto
...... (1 million rows)...

※ Example search: 'Suzuki@example.COM' → must return user_id=2.

Expected Output

Output when searching for example 'Suzuki@Example.com':

user_idemailname
2Suzuki@Example.comSuzuki

Rows read: 1 (a direct hit through an Index Scan). No full scan of 1 million rows occurs.

QUESTION 4
The OFFSET Trap and Keyset Pagination — Make Page 100 as Fast as Page 1
OFFSETKeyset paginationSeek methodComposite tuple comparison
Background

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)
What tuple comparison means: (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.
Problem

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.

Source table
- articles (1 million rows, index: created_at DESC, id DESC)
idtitlecreated_at
510New A2026-05-04 15:00
505New B2026-05-04 12:00
501Article X2026-05-04 10:00
499Article Y2026-05-04 10:00
498Article Z2026-05-04 09:00
490Older A2026-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

Expected output (first few rows of the next page):

idtitlecreated_at
499Article Y2026-05-04 10:00
498Article Z2026-05-04 09:00
490Older A2026-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.

QUESTION 5
Composite-Index Column Order and INCLUDE — Do not read the heap with Index-Only Scan
Composite indexColumn orderINCLUDEIndex-Only Scan
Background

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);
Difference between INCLUDE and key columns: An 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+).
Problem

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;
Source table
- tasks (50 million rows / status distribution: 'pending'≈1%, 'done'≈99%)
task_idstatuscreated_attitleassignee
1done2026-04-01......
2pending2026-05-03Invoice reviewSato
3pending2026-05-02Inventory countSuzuki
4done2026-05-01......
5pending2026-05-05A/B test aggregationTanaka

※ 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 Output

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.