Performance Optimization — Learn Expression Indexes, LAG/LEAD, Keyset Pagination, and LATERAL in Practice
AdvancedExpression indexesLAG / LEADMixed-direction keyset paginationLATERAL JOINPostgreSQL-compatible5 questions
QUESTION 6
Expression Indexes — Make “wrapping a column in a function” Sargable again
Expression indexFunctional indexSargableCase-insensitive
Background

In Basic Q6, we learned to match the literal side to the column's type. In practice, however, some requirements cannot be expressed without wrapping a column in a function. A representative example is case-insensitive search: we want to match the same customer whether the application sends Alice@Example.COM or alice@example.com.
In this situation, a normal idx(email) is powerless against LOWER(email) = ? (the moment the column is wrapped in a function, it violates Sargability). The solution is an “expression index” that indexes the expression itself. If we create an index that preorders the value of the expression LOWER(email), a search using the same expression becomes an equality lookup again.

-- ✗ A normal index dies when LOWER is applied — full table scan
CREATE INDEX idx_users_email ON users (email);
WHERE LOWER(email) = 'alice@example.com'

-- ✓ Index the expression itself → a search using the same expression is Sargable again
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
WHERE LOWER(email) = 'alice@example.com'   -- The expression exactly matches the index
Core idea: “An index cannot be used when a column is wrapped in a function” is true for a normal index. Indexing the wrapped expression preserves the principle while allowing function-wrapped code — Sargability means a form usable for equality or range lookup, and an expression index expands the target of that form to the value of an expression.
Problem

From the users table (1 million actual rows), retrieve members whose email matches 'Alice@Example.COM' case-insensitively. Application input varies in capitalization, and the table data also contains mixed capitalization for historical reasons. Keep the function-wrapped predicate, but make it an index equality lookup (show the required index as well). Return user_id, email (user_id ascending).

Source table
- users (email is varchar, a normal index exists, capitalization varies)
user_idemail
1alice@example.com
2bob@example.com
3ALICE@example.com
4charlie@example.com
5aLiCe@Example.Com

※ First predict what happens if LOWER(email) = ? is written when only the normal idx(email) exists.

Expected Output

Expected output (user_id ascending):

user_idemail
1alice@example.com
3ALICE@example.com
5aLiCe@Example.Com

The normal-index version performs a Seq Scan and computes LOWER for all 1 million rows. The expression-index version performs one equality lookup for 'alice@example.com'.

QUESTION 7
LAG / LEAD and Difference Calculations — One pass with a window instead of a self JOIN
LAG / LEADWindow functionsDay-over-day changeOne pass
Background

In Basic Q7, we used ROW_NUMBER to obtain “the rank within a group.” In the advanced set, LAG / LEAD lets us reference the value in the preceding or following row within the same partition on the spot. Day-over-day changes, differences from the immediately previous event, and intervals between consecutive events all reduce to this pattern: order rows and look at their neighbors.

Writing the same requirement with a self JOIN or correlated subquery means re-searching for “the previous row” for every row, creating fertile ground for N+1 behavior and fan-out. A window function can retrieve “the previous row” with one table scan — rows flow in partition-key order, while the implementation simply remembers the preceding value.

-- ✗ Find the previous day with a self LEFT JOIN + correlated MAX → per-row search + fan-out risk
SELECT p1.price - p2.price AS diff
FROM price_history p1
LEFT JOIN price_history p2
  ON p2.product_id = p1.product_id
 AND p2.recorded_at = (SELECT MAX(recorded_at) FROM price_history x
                          WHERE x.product_id = p1.product_id AND x.recorded_at < p1.recorded_at)

-- ✓ One pass with LAG; rows remain intact and each row receives its difference
price - LAG(price) OVER (PARTITION BY product_id ORDER BY recorded_at) AS diff
Core idea: LAG(col) returns the value of col in the immediately preceding row in the same partition, according to ORDER BY (the first row of a partition is NULL, or use LAG(col, 1, 0) to specify a default). It does not reference across partition boundaries — this is the part that guarantees correctness at the syntax level, where a self JOIN would be difficult to express.
Problem

From price_history, retrieve each product's daily price with the “difference from the previous day”. The first day for each product may have a NULL difference. Requirements:

  • Do not use a self JOIN or correlated subquery (do not trigger a search for every row)
  • Scan the table only once (one pass with a window function)
  • Do not calculate a difference across products (do not subtract product 2's first day from product 1's last day)

Return product_id, recorded_at, price, diff (product_id, recorded_at ascending).

Source table
- price_history (index on (product_id, recorded_at))
product_idrecorded_atprice
12026-06-011000
12026-06-021100
12026-06-031050
22026-06-01500
22026-06-02520
22026-06-03510

※ Before comparing with this answer, first imagine what shape this would take without a window function.

Expected Output

Expected output (product_id, recorded_at ascending):

product_idrecorded_atpricediff
12026-06-011000NULL
12026-06-021100100
12026-06-031050-50
22026-06-01500NULL
22026-06-0252020
22026-06-03510-10

The first day of each product has no preceding day, so its difference is NULL. Do not subtract product 2's first day (500) from product 1's last day (1050); that is the effect of PARTITION.

QUESTION 8
Mixed DESC/ASC Keyset Pagination — Expand to OR when row-value comparison cannot be used
Composite keysetMixed sort directionsOR expansionComposite index
Background

In Basic Q8, we represented a cursor with row-value (tuple) comparison (a, b) < (x, y) for a sort where every direction was the same, such as ORDER BY published_at DESC, article_id DESC.
Real-world sorts often mix directions: “priority DESC, then due date ASC for ties, then id DESC for remaining ties.” A simple row-value comparison cannot be used here (the lexicographic order of tuple comparison assumes the same direction for every column; once ASC is mixed in, its meaning no longer matches ORDER BY).
The solution is OR expansion: decompose the condition for the “next row” after the cursor column by column, like carrying digits in addition. If the index is ordered as (priority DESC, due_date ASC, task_id DESC), including the directions, the keyset achieves zero sort steps and one seek.

-- ORDER BY priority DESC, due_date ASC, task_id DESC
-- Last row on the previous page: (priority=2, due_date='2026-06-07', task_id=4)

-- ✓ OR expansion: list the per-column conditions in carry order
WHERE priority < 2                                              -- ① Priority decreases
   OR (priority = 2 AND due_date > DATE '2026-06-07')              -- ② Same priority; later date (ASC means >)
   OR (priority = 2 AND due_date = DATE '2026-06-07' AND task_id < 4)   -- ③ If all equal, smaller id (DESC means <)
Core idea: Each OR branch represents the column that determined the ordering position. For a DESC column, select values smaller than the cursor; for an ASC column, select values larger than the cursor — the direction of each ORDER BY column determines the direction of its inequality. When the composite index uses the same directions, the index's “next key” is exactly the head of the next page.
Problem

Display a task list in pages of 2 using ORDER BY priority DESC, due_date ASC, task_id DESC. The last key on the previous page is (priority, due_date, task_id) = (2, '2026-06-07', 4). For this mixed-direction order, use neither OFFSET nor a simple row-value comparison; retrieve the next 2 rows. Include the index definition. Return task_id, title, priority, due_date.

Source table
- tasks (1 million actual rows)
task_idtitleprioritydue_date
1Urgent response A32026-06-10
2Urgent response B32026-06-15
3Normal response A22026-06-05
4Normal response B22026-06-07
5Normal response C22026-06-07
6Normal response D22026-06-09
7Normal response E22026-06-12
8Low-priority A12026-06-20

※ The order (priority DESC, due_date ASC, task_id DESC) is 1, 2, 3, 5, 4, 6, 7, 8 (ids 4 and 5 have the same priority and due date, so id=5 comes first under id DESC). Page 1 = 1,2 / page 2 = 3,5 / after cursor (2, '06-07', 4) comes 6, 7.

Expected Output

Expected output:

task_idtitleprioritydue_date
6Normal response D22026-06-09
7Normal response E22026-06-12

id=4 is the cursor itself, so it is excluded (task_id < 4 is FALSE). id=5 is before id=4 under id DESC with the same priority and due date, so it has already been displayed and is excluded (task_id < 4 is FALSE). The three OR branches divide the work correctly.

QUESTION 9
LATERAL JOIN — Seek each group's top N rows directly in the index
LATERALTop N per groupSemi Join extensionIndex seek
Background

In Basic Q9, we learned to use EXISTS to decide whether something exists. The advanced form is LATERAL JOIN: a join that executes the right-hand subquery for each left-hand row using that row's value. Its essence becomes clear if you think of it as rewriting a correlated subquery in JOIN form.
Its strength appears in “top N per group.” Basic Q7's ROW_NUMBER + WHERE rn <= N assigns a number to every row before filtering, which is disadvantageous when groups contain huge numbers of rows. LATERAL needs only N index seeks per group — its power is dramatic when the number of groups is much smaller than the number of rows within each group.

-- △ Assign row numbers to every row before taking the top 3: 10 million rn assignments
WITH ranked AS (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY sold_at DESC) AS rn
  FROM products
) SELECT * FROM ranked WHERE rn <= 3;

-- ✓ Only the number of categories × 3 index seeks is needed
SELECT c.*, p.*
FROM   categories c
JOIN LATERAL (
  SELECT ... FROM products WHERE category_id = c.cat_id    -- Reference c's value
  ORDER BY sold_at DESC LIMIT 3
) p ON TRUE;
Core idea: LATERAL is a JOIN that can use a left row's value in the right subquery. The right-hand LIMIT 3 is evaluated independently for each row; with an index on the group key plus sort key, the work ends after a seek and 3 reads. Think of it as extending Basic Q9's EXISTS (stop after 1 row = Semi Join) to “read up to N rows.”
Problem

In an e-commerce administration screen, display the “3 latest-sold products in each category.” categories has 10 categories, products has 10 million rows, and an index exists on (category_id, sold_at DESC, product_id DESC). Do not assign row numbers to every row with ROW_NUMBER; write the form that performs only 3 index seeks per category. Return cat_id, category, product_id, product_name, sold_at (cat_id ascending, then sold_at descending within each category).

Source tables
- categories (10 rows; 3 are excerpted here)
cat_idname
1Electronics
2Books
3Apparel
- products (10 million actual rows; 4 rows each from categories 1 and 2, and 2 from category 3 are excerpted)
product_idcategory_idnamesold_at
1011Earbuds2026-06-10
1021Speaker2026-06-09
1031Cable2026-06-08
1041Charger2026-06-01
2012SQL book2026-06-12
2022Novel A2026-06-11
2032Illustrated guide2026-06-05
2042Magazine2026-06-02
3013T-shirt2026-06-07
3023Hat2026-06-03

※ The ROW_NUMBER version scans and numbers every product internally (10 million rows), then narrows to 30 rows with rn <= 3. The LATERAL version finishes with 10 categories × 3 rows = 30 index seeks.

Expected Output

Expected output (cat_id ascending → sold_at descending):

cat_idcategoryproduct_idproduct_namesold_at
1Electronics101Earbuds2026-06-10
1Electronics102Speaker2026-06-09
1Electronics103Cable2026-06-08
2Books201SQL book2026-06-12
2Books202Novel A2026-06-11
2Books203Illustrated guide2026-06-05
3Apparel301T-shirt2026-06-07
3Apparel302Hat2026-06-03

Categories 1 and 2 return the top 3 of 4 products; category 3 returns both products (LIMIT 3, but only 2 match). Total: 8 rows.

QUESTION 10
Comprehensive Problem — Put four advanced techniques into one support-management dashboard query
ComprehensiveExpression indexLATERALComposite keysetPartial index
Background

As the capstone of the advanced set, we will build a common real-world support-ticket management dashboard: “retrieve open tickets for a specific customer in priority order, with the latest reply for each ticket, using a cursor.” The four advanced techniques plus the partial index from the basic set divide the work cleanly.

① A partial index (Basic Q5) indexes only the minority with status='open'; ② an expression index (Q6) makes email search case-insensitive and Sargable; ③ mixed DESC/ASC keyset pagination (Q8) expands the cursor into OR branches; ④ LATERAL JOIN (Q9) seeks the latest reply for each ticket directly in the index. The spirit of Q7 (do not push “order rows and look at the neighbor” processing into ETL; finish it in the query) also connects directly to this LATERAL structure.

-- Overall shape (pseudocode)
SELECT ..., lr.*
FROM   tickets t
JOIN LATERAL (...) lr ON TRUE                          -- ④ Latest reply for each ticket
WHERE  t.status = 'open'                                    -- ① Partial-index implication
  AND  LOWER(t.customer_email) = LOWER(?)                  -- ② Case-insensitive with an expression index
  AND  (priority < ? OR ...)                                -- ③ OR expansion for mixed DESC/ASC cursor
ORDER BY priority DESC, due_date ASC, ticket_id DESC LIMIT ?;
Goal: The four techniques are powerful individually, but real queries reveal their value where multiple concerns overlap. The same table is filtered (expression index), ordered (composite keyset), and enriched with a summary from a related table (LATERAL) — the goal is to develop the design sense to combine these three layers in one query.
Problem

Build a SaaS customer-support screen showing “open tickets for a specific customer (priority order + latest reply)”. Requirements:

  • Only tickets with customer_email matching 'Alice@Example.com' (case-insensitive) and status = 'open'
  • Order by priority DESC, due_date ASC, ticket_id DESC (same form as Advanced Q8; mixed directions)
  • Join and display the latest reply for each ticket (replied_at DESC, reply_id DESC)
  • Starting after the previous-page cursor (priority, due_date, ticket_id) = (2, '2026-06-09', 104), retrieve the next page of 2 rows
  • Do not use OFFSET; show the necessary indexes as well (partial + expression + ordering indexes)
Source tables
- tickets (1 million actual rows; 'open' is about 3%)
ticket_idcustomer_emailsubjectprioritydue_datestatus
101alice@example.comOrder not received32026-06-05open
102Alice@Example.comRefund request22026-06-08open
103alice@example.comPassword reset22026-06-08open
104alice@example.comCancellation request22026-06-09open
105alice@example.comDelivery delay12026-06-15open
106bob@example.comWrong item32026-06-06open
107ALICE@example.comDamaged item22026-06-11open
108alice@example.com(completed)22026-06-04closed
- replies
reply_idticket_idreplied_atbody
11012026-06-06 09:00Waiting for a response from logistics
21022026-06-08 14:00Refund processing will begin
31032026-06-09 10:00Reset link sent
41042026-06-10 11:00Cancellation accepted
51052026-06-15 09:00Delivery scheduled: 06-16
61072026-06-11 16:00Replacement process started

※ The 6 open tickets related to Alice (case-insensitive) are 101, 102, 103, 104, 105, and 107. Their order (priority DESC, due_date ASC, ticket_id DESC) is 101, 103, 102, 104, 107, 105. Page 1 = 101,103 / page 2 = 102,104 / after cursor (2, '06-09', 104) comes 107, 105.

Expected Output

Expected output (next page, 2 rows):

ticket_idsubjectprioritydue_datelast_replied_atlast_message
107Damaged item22026-06-112026-06-11 16:00Replacement process started
105Delivery delay12026-06-152026-06-15 09:00Delivery scheduled: 06-16

There is no full tickets scan, replies is probed for only the latest row, and not one unnecessary row is read — the four advanced techniques plus the partial index create speed through multiplication, not addition.