SQL Performance Tuning — Applied LAG/LEAD and Keyset Paging

ADVExpression indexesLAG / LEADMixed-direction keyset paginationLATERAL JOINPostgreSQL-compatible5 questions
1 / 5 · In progress 0 / 5 completed
QUESTION 6

Expression Indexes — Make “wrapping a column in a function” Sargable again

Expression indexFunctional indexSargableCase-insensitive
Background

In the basic set, 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
Expected Output
user_idemail
1alice@example.com
3ALICE@example.com
5aLiCe@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 the basic set, 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
Expected Output
product_idrecorded_atpricediff
12026-06-011000NULL
12026-06-021100100
12026-06-031050-50
22026-06-01500NULL
22026-06-0252020
22026-06-03510-10
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 the basic set, 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
Expected Output
task_idtitleprioritydue_date
6Normal response D22026-06-09
7Normal response E22026-06-12
QUESTION 9

LATERAL JOIN — Seek each group's top N rows directly in the index

LATERALTop N per groupSemi Join extensionIndex seek
Background

In the basic set, 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 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 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
Expected Output
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
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 indexes only the minority with status='open'; ② an expression index makes email search case-insensitive and Sargable; ③ mixed DESC/ASC keyset pagination expands the cursor into OR branches; ④ LATERAL JOIN seeks the latest reply for each ticket directly in the index. The spirit (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 (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
Expected Output
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