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
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 |
※ First predict what happens if LOWER(email) = ? is written when only the normal idx(email) exists.
Expected output (user_id ascending):
| user_id | |
|---|---|
| 1 | alice@example.com |
| 3 | ALICE@example.com |
| 5 | aLiCe@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'.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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
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 |
※ Before comparing with this answer, first imagine what shape this would take without a window function.
Expected output (product_id, recorded_at ascending):
| 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 |
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.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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 <)
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 |
※ 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:
| task_id | title | priority | due_date |
|---|---|---|---|
| 6 | Normal response D | 2 | 2026-06-09 |
| 7 | Normal response E | 2 | 2026-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.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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;
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.”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 |
※ 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 (cat_id ascending → sold_at descending):
| 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 |
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.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free
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 ?;
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(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)
| 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 |
※ 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 (next page, 2 rows):
| 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 |
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.
- Read every model answer, explanation and table-transition visualization across all 48 advanced sets (240 questions)
- One-time purchase — no subscription. Future advanced sets are included
- Background, problem and expected output stay free