Expression Indexes — Make “wrapping a column in a function” Sargable again
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
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).
| user_id | |
|---|---|
| 1 | alice@example.com |
| 2 | bob@example.com |
| 3 | ALICE@example.com |
| 4 | charlie@example.com |
| 5 | aLiCe@Example.Com |
| user_id | |
|---|---|
| 1 | alice@example.com |
| 3 | ALICE@example.com |
| 5 | aLiCe@Example.Com |
LAG / LEAD and Difference Calculations — One pass with a window instead of a self JOIN
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
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.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).
| product_id | recorded_at | price |
|---|---|---|
| 1 | 2026-06-01 | 1000 |
| 1 | 2026-06-02 | 1100 |
| 1 | 2026-06-03 | 1050 |
| 2 | 2026-06-01 | 500 |
| 2 | 2026-06-02 | 520 |
| 2 | 2026-06-03 | 510 |
| product_id | recorded_at | price | diff |
|---|---|---|---|
| 1 | 2026-06-01 | 1000 | NULL |
| 1 | 2026-06-02 | 1100 | 100 |
| 1 | 2026-06-03 | 1050 | -50 |
| 2 | 2026-06-01 | 500 | NULL |
| 2 | 2026-06-02 | 520 | 20 |
| 2 | 2026-06-03 | 510 | -10 |
Mixed DESC/ASC Keyset Pagination — Expand to OR when row-value comparison cannot be used
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 <)
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.
| task_id | title | priority | due_date |
|---|---|---|---|
| 1 | Urgent response A | 3 | 2026-06-10 |
| 2 | Urgent response B | 3 | 2026-06-15 |
| 3 | Normal response A | 2 | 2026-06-05 |
| 4 | Normal response B | 2 | 2026-06-07 |
| 5 | Normal response C | 2 | 2026-06-07 |
| 6 | Normal response D | 2 | 2026-06-09 |
| 7 | Normal response E | 2 | 2026-06-12 |
| 8 | Low-priority A | 1 | 2026-06-20 |
| task_id | title | priority | due_date |
|---|---|---|---|
| 6 | Normal response D | 2 | 2026-06-09 |
| 7 | Normal response E | 2 | 2026-06-12 |
LATERAL JOIN — Seek each group's top N rows directly in the index
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;
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.”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).
| cat_id | name |
|---|---|
| 1 | Electronics |
| 2 | Books |
| 3 | Apparel |
| product_id | category_id | name | sold_at |
|---|---|---|---|
| 101 | 1 | Earbuds | 2026-06-10 |
| 102 | 1 | Speaker | 2026-06-09 |
| 103 | 1 | Cable | 2026-06-08 |
| 104 | 1 | Charger | 2026-06-01 |
| 201 | 2 | SQL book | 2026-06-12 |
| 202 | 2 | Novel A | 2026-06-11 |
| 203 | 2 | Illustrated guide | 2026-06-05 |
| 204 | 2 | Magazine | 2026-06-02 |
| 301 | 3 | T-shirt | 2026-06-07 |
| 302 | 3 | Hat | 2026-06-03 |
| cat_id | category | product_id | product_name | sold_at |
|---|---|---|---|---|
| 1 | Electronics | 101 | Earbuds | 2026-06-10 |
| 1 | Electronics | 102 | Speaker | 2026-06-09 |
| 1 | Electronics | 103 | Cable | 2026-06-08 |
| 2 | Books | 201 | SQL book | 2026-06-12 |
| 2 | Books | 202 | Novel A | 2026-06-11 |
| 2 | Books | 203 | Illustrated guide | 2026-06-05 |
| 3 | Apparel | 301 | T-shirt | 2026-06-07 |
| 3 | Apparel | 302 | Hat | 2026-06-03 |
Comprehensive Problem — Put four advanced techniques into one support-management dashboard query
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 ?;
Build a SaaS customer-support screen showing “open tickets for a specific customer (priority order + latest reply)”. Requirements:
- Only tickets with
customer_emailmatching'Alice@Example.com'(case-insensitive) andstatus = '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)
| ticket_id | customer_email | subject | priority | due_date | status |
|---|---|---|---|---|---|
| 101 | alice@example.com | Order not received | 3 | 2026-06-05 | open |
| 102 | Alice@Example.com | Refund request | 2 | 2026-06-08 | open |
| 103 | alice@example.com | Password reset | 2 | 2026-06-08 | open |
| 104 | alice@example.com | Cancellation request | 2 | 2026-06-09 | open |
| 105 | alice@example.com | Delivery delay | 1 | 2026-06-15 | open |
| 106 | bob@example.com | Wrong item | 3 | 2026-06-06 | open |
| 107 | ALICE@example.com | Damaged item | 2 | 2026-06-11 | open |
| 108 | alice@example.com | (completed) | 2 | 2026-06-04 | closed |
| reply_id | ticket_id | replied_at | body |
|---|---|---|---|
| 1 | 101 | 2026-06-06 09:00 | Waiting for a response from logistics |
| 2 | 102 | 2026-06-08 14:00 | Refund processing will begin |
| 3 | 103 | 2026-06-09 10:00 | Reset link sent |
| 4 | 104 | 2026-06-10 11:00 | Cancellation accepted |
| 5 | 105 | 2026-06-15 09:00 | Delivery scheduled: 06-16 |
| 6 | 107 | 2026-06-11 16:00 | Replacement process started |
| ticket_id | subject | priority | due_date | last_replied_at | last_message |
|---|---|---|---|---|---|
| 107 | Damaged item | 2 | 2026-06-11 | 2026-06-11 16:00 | Replacement process started |
| 105 | Delivery delay | 1 | 2026-06-15 | 2026-06-15 09:00 | Delivery scheduled: 06-16 |