Performance Optimization — Learn Type Conversion, ROW_NUMBER, and Keyset Pagination from the Basics
BasicImplicit Type ConversionROW_NUMBEROFFSET TrapsKeyset PaginationEXISTS and Early ExitPostgreSQL5 questions
QUESTION 6
Implicit Type Conversion — A Type-Mismatched Comparison Quietly Kills the Index (or Suddenly Fails)
Implicit type conversionCastingSargableAlign types
Background

When the types of a column and a literal (or parameter) do not match in a WHERE clause, what happens is up to the DBMS. PostgreSQL raises an error for a varchar = integer comparison, while MySQL and Oracle implicitly cast the column before comparing it. The moment the column is cast, it is equivalent to wrapping the column in a function—the index is removed from consideration (a Sargable violation). Because the cast was inserted even though it is not written in the code, this trap is harder to spot than an explicit function wrapper.

-- ✗ varchar column × numeric literal → PostgreSQL errors immediately / MySQL etc. implicitly cast the column
WHERE member_code = 250

-- ✗ Cast the "column side" to avoid the error → convert every row + runtime error on non-numeric data
WHERE member_code::int = 250

-- ✓ Align the "literal side" with the column type → a straightforward varchar comparison that uses the index
WHERE member_code = '250'
The core idea: The writer should align the types on both sides of a comparison. When aligning them, move the literal/parameter side toward the column, not the column toward the literal. The same rule applies to the type of an application's bind variable (whether it is sent as a number or a string).
Problem

From the members table (1 million actual rows, with an index on member_code), retrieve the member whose member code is 250. member_code is a varchar type inherited from a legacy system; most values are numeric, but legacy codes such as 'A-100' also exist. Write the query in a form that can use the index and has no risk of a runtime error. The output columns must be member_id, member_code, name.

Tables used
- members (member_code is varchar; index available)
member_idmember_codename
1100Sato
2250Suzuki
3A-100Tanaka
430Ito
51200Watanabe

※ Also consider what happens to the 'A-100' row if you write member_code::int = 250.

Expected Output

Expected output:

member_idmember_codename
2250Suzuki

The column-cast version cannot use the index and the query is aborted by a runtime error the moment it reads 'A-100' (the read order depends on the plan, so the incident is not reproducible).

Model Answer
SELECT   member_id, member_code, name
FROM     members
WHERE    member_code = '250'   -- Align the literal side with the column type (varchar) → index equality lookup
ORDER BY member_id;

-- ✗ member_code = 250      … PostgreSQL has no comparison operator and errors
-- ✗ member_code::int = 250 … Casts every row (no index) + runtime error on 'A-100'

/*
  Logical evaluation order:
  1. FROM members               → choose the source
  2. WHERE member_code = '250'  → index equality lookup with no cast
  3. SELECT                     → read only the matching row
  4. ORDER BY member_id         → effectively no extra cost
  */
Explanation (table transitions & key points)
SELECT member_id, member_code, name FROM members WHERE member_code = '250' ORDER BY member_id;
LEGEND
Rows read / loaded
1. Source table — member_code is varchar (legacy-format codes are mixed in)
FROM members (index on member_code)member_code is a string type (varchar) inherited from a legacy system. The contents are mostly numeric, but legacy formats such as 'A-100' also exist. A “numeric-looking string” is the gateway to the trap of wanting to search with a numeric literal.
1 / 5
member_idmember_code (varchar)name
1'100'Sato
2'250'Suzuki
3'A-100'Tanaka
4'30'Ito
5'1200'Watanabe
5 rows (the actual table has 1 million rows; index on member_code)
LEARNING POINTS
Implicit casts are "invisible Sargable violations": A function wrapper (such as the previously learned DATE_TRUNC(ordered_at)) is visible in the code, but an implicit cast is inserted by the DBMS, making the code look innocent. The clue is in EXPLAIN: if a condition contains a cast you do not remember writing, such as (member_code)::integer, suspect a type mismatch.
The direction of alignment determines the outcome: Even when the goal is the same—aligning the types—a column-side cast kills the index, while moving the literal side keeps the index alive. A literal or constant can be converted once before execution, but a column must be converted for every row. Remember: “converting a constant once is cheap; converting a column for every row is expensive.” Bind variables follow the same rule: send them from the application as strings (the equivalent of setString).
Runtime errors are data-dependent and hard to reproduce: An error from a ::int cast occurs when a row that cannot be converted to a number is actually read. The planner is free to choose the evaluation and scan order, so the query works in a test environment without 'A-100' and fails in production with it—a type of incident that slips past basic verification. Guarantee type correctness through the query shape instead of relying on accidental data.
ANTI-PATTERNS
Play whack-a-mole by adding casts to suppress errors: After ::int fails, you may add CASE WHEN member_code ~ '^[0-9]+$' THEN member_code::int END = 250 and keep layering defensive code. The query becomes heavier and harder to read. If the value is numeric, fix the schema to use a numeric type. If that is impossible, the next-best choice is to compare it as a string.
Leave a type mismatch in a JOIN key: A JOIN that connects a.user_id (int) and b.user_code (varchar) with ON a.user_id::text = b.user_code pays for a cast of every row on one side on every join. This is common and hard to notice at system-integration and data-migration boundaries. It is a schema designer's job to standardize keys across master data, including their types.
Practical column: Types change not only speed, but the meaning of comparisons
The strings '250' and '0250' are different values, while numeric 250 and 0250 are equal. String ordering also gives '1200' < '30' (lexicographic order), which conflicts with numeric intuition. In other words, type choice defines not only index performance but the very meaning of equality and ordering. The usual rule is “use strings for digits that are not calculated, such as codes and phone numbers; use numeric types for numbers that are calculated.” Either way, the principle of aligning both sides of a comparison does not change. Q7 moves to “the latest row per group” and introduces the window function as a new tool replacing the correlated subquery from Q1.
QUESTION 7
Window Function ROW_NUMBER — Get the Latest Row per Group in a Single Scan
ROW_NUMBERPARTITION BYLatest rowOne pass
Background

Getting the latest row within each group (greatest-n-per-group), such as “the latest login for each user,” is one of the most common requirements in practice. If you write logged_at = (SELECT MAX(...)) with a correlated subquery, you get not only N+1 (Q1) but also a correctness problem where a timestamp tie returns two rows. With the window function ROW_NUMBER(), PARTITION BY divides the groups and the within-group ordering assigns a number to every row—the whole table can be processed in a single scan (one pass).

-- ✗ Search for MAX row by row (N+1) + both rows in a timestamp tie are returned
WHERE logged_at = (SELECT MAX(logged_at) FROM logins x
                   WHERE x.user_id = l.user_id)

-- ✓ Number each partition → keep only rn = 1 outside (always exactly one row)
ROW_NUMBER() OVER (PARTITION BY user_id
                    ORDER BY logged_at DESC, login_id DESC) AS rn
The core idea: GROUP BY “collapses” rows, while a window function keeps the rows and attaches a group-level calculation to each one. A window function cannot be written directly in the WHERE clause (WHERE is evaluated before the window); wrap it in a subquery or CTE and filter outside.
Problem

From the logins table, retrieve the latest login for each user (timestamp and device). If multiple logins have the same timestamp, choose the one with the larger login_id, guaranteeing exactly one row per user. Write it so the table is scanned only once. The output columns must be user_id, logged_at, device (user_id ascending).

Tables used
- logins (note that user_id=3 has two rows at the same time)
login_iduser_idlogged_atdevice
112026-06-01 09:00mobile
222026-06-01 10:00pc
312026-06-03 12:00pc
432026-06-02 08:00pc
522026-06-04 18:00mobile
632026-06-02 08:00tablet
712026-06-02 20:00tablet

※ Before writing it, predict how many rows user_id=3 would produce with a correlated MAX subquery.

Expected Output

Expected output (user_id ascending):

user_idlogged_atdevice
12026-06-03 12:00pc
22026-06-04 18:00mobile
32026-06-02 08:00tablet

user_id=3 has login_id 4 and 6 at the same time. The tie-breaker (login_id DESC) selects tablet (id=6) as “the latest row.” The MAX comparison version returns both rows.

Model Answer
SELECT   user_id, logged_at, device
FROM (
  SELECT user_id, logged_at, device,
         ROW_NUMBER() OVER (PARTITION BY user_id                      -- one partition per user
                            ORDER BY logged_at DESC, login_id DESC) AS rn  -- tie-breaker is required
  FROM   logins
) t
WHERE    rn = 1   -- a window function cannot be written directly in WHERE; filter outside
ORDER BY user_id;

-- Alternative (PostgreSQL only, concise):
-- SELECT DISTINCT ON (user_id) user_id, logged_at, device
-- FROM logins ORDER BY user_id, logged_at DESC, login_id DESC;

/*
  Logical evaluation order:
  1. Inner FROM logins    → one scan
  2. Window               → partition by user_id and assign ROW_NUMBER
  3. Outer WHERE rn = 1   → one row per partition
  4. SELECT               → finalize the three columns
  5. ORDER BY user_id      → sort ascending
  */
Explanation (table transitions & key points)
SELECT user_id, logged_at, device FROM ( SELECT user_id, logged_at, device, ROW_NUMBER() OVER ( PARTITION BY user_id ORDER BY logged_at DESC, login_id DESC) AS rn FROM logins ) t WHERE rn = 1 ORDER BY user_id;
LEGEND
Rows read / loaded
1. Source table — Login history for three users
FROM loginsWe want the latest one row per user. Notice that user_id=3 has two logins at 08:00 on June 2 (pc / tablet)—this same-time tie tests whether the query is correct.
1 / 5
login_iduser_idlogged_atdevice
112026-06-01 09:00mobile
222026-06-01 10:00pc
312026-06-03 12:00pc
432026-06-02 08:00pc
522026-06-04 18:00mobile
632026-06-02 08:00tablet
712026-06-02 20:00tablet
7 rows (3 users; user_id=3 has a timestamp tie)
LEARNING POINTS
GROUP BY collapses; a window attaches: GROUP BY reduces a group to one row, so it cannot naturally return the other columns from the latest row (such as device). A window function keeps the rows and adds a partition-level result to each one; filtering to rn=1 then gives you every column from the latest row. Execution also consists of ordering by the partition key and making one pass (or using the index order directly), with no per-row lookup.
A tie-breaker is a correctness requirement: With only ORDER BY logged_at DESC, rn for equal timestamps may change from run to run (non-deterministic). Add a unique key (login_id) last to make the ranking unique and structurally prevent “the result changed when I reran it.” In contrast to the MAX correlated-subquery version returning two rows on a tie, ROW_NUMBER's fundamental strength is that it guarantees “exactly one” at the query-shape level.
The evaluation order requires a subquery: The logical order is FROM → WHERE → GROUP BY → HAVING → SELECT (where window functions run) → ORDER BY. WHERE comes before the window, so WHERE rn = 1 cannot be written at the same level. Wrapping it in a subquery/CTE is not a detour; it is required by the evaluation order. PostgreSQL also offers DISTINCT ON, and systems that support it offer QUALIFY as a shorthand.
ANTI-PATTERNS
Keep using a MAX correlated subquery for “latest”: In addition to N+1 cost and the correctness problem of duplicate ties, asking for the latest device as well makes the subquery multiply by column. When you see a latest-row requirement, start by applying the four-part ROW_NUMBER pattern.
Fabricate a row by assembling GROUP BY + MAX: If you put MAX(logged_at), MAX(device) next to GROUP BY user_id, the timestamp comes from one row while device comes from another—you have created a Frankenstein row that never existed. It does not error and returns plausible-looking values, making it a nasty correctness bug. Always ask whether the values are guaranteed to come from the same row.
Practical column: Keep a toolbox for greatest-n-per-group
“The latest/top N per group” is a recurring pattern in every domain: latest orders, current statuses, and top performers in a department. The basic form is this question's ROW_NUMBER (change it to rn <= N for the top N), DISTINCT ON is concise for one row in PostgreSQL, and when there are few groups with many rows per group, LATERAL + LIMIT 1 can probe an index once per partition. If you also align an index as (user_id, logged_at DESC, login_id DESC), the previously learned “remove the sort step” helps the window operation too. Q8 moves to another standard list-screen problem—the pagination trap.
QUESTION 8
The OFFSET Trap and Keyset Pagination — Seek Instead of Reading and Throwing Away Deep Pages
LIMIT / OFFSETKeyset paginationRow-value comparisonSeek method
Background

The standard pagination pattern LIMIT 20 OFFSET 100000 internally reads 100,020 rows and throws away 100,000 of them. Because the cost of OFFSET is proportional to the number of discarded rows, later pages become slower; inserting a row midway can also cause page drift (duplicates and omissions). Keyset pagination (the seek method) remembers “the key of the last row on the previous page” and reads the index from there—so every page has a constant cost.

-- ✗ Page 3: read 6 rows and discard 4 (imagine page 100,000...)
ORDER BY published_at DESC, article_id DESC LIMIT 2 OFFSET 4

-- ✓ Keyset: read only the continuation after the last key on the previous page
WHERE (published_at, article_id) < (TIMESTAMP '2026-05-05 13:00', 5)
ORDER BY published_at DESC, article_id DESC LIMIT 2
Row-value (tuple) comparison: (a, b) < (x, y) means lexicographically a < x OR (a = x AND b < y). The rule is to write the cursor condition with the same columns and same directions as ORDER BY; including a unique key keeps pages stable even when timestamps tie.
Problem

Display an article list from the articles table (1 million actual rows, with a composite index on (published_at, article_id)) in newest-first order (published_at DESC, article_id DESC), two rows per page. The last row on page 2 was (published_at, article_id) = ('2026-05-05 13:00', 5). Without using OFFSET, retrieve the two rows on page 3. The output columns must be article_id, title, published_at.

Tables used
- articles (ids 5 and 6 have the same timestamp; watch the tie)
article_idtitlepublished_at
1Spring feature2026-05-01 09:00
2New feature introduction2026-05-02 10:00
3Incident report2026-05-03 11:00
4Interview article2026-05-04 12:00
5Careers2026-05-05 13:00
6Engineering blog2026-05-05 13:00
7Summer feature2026-05-06 14:00
8Announcement2026-05-07 15:00

※ The newest-first order is 8→7→6→5→4→3→2→1. Page 1=8,7 / page 2=6,5. Preventing tied id=6 from leaking into page 3 is the keyset design challenge.

Expected Output

Expected output (page 3, newest first):

article_idtitlepublished_at
4Interview article2026-05-04 12:00
3Incident report2026-05-03 11:00

id=6 has (13:00, 6), which is ahead in the sort order of cursor (13:00, 5) because id DESC puts 6 first; it was already shown on page 2. The tuple comparison excludes it correctly.

Model Answer
SELECT   article_id, title, published_at
FROM     articles
WHERE    (published_at, article_id) < (TIMESTAMP '2026-05-05 13:00', 5)  -- key of the last row on the previous page
ORDER BY published_at DESC, article_id DESC  -- same columns and directions as the cursor
LIMIT    2;

-- Assumed index: CREATE INDEX ON articles (published_at, article_id); (scan backward for DESC)

/*
  Logical evaluation order:
  1. FROM articles       → choose the source
  2. WHERE row-value comparison → convert to an index seek
  3. ORDER BY             → use index order with no sort
  4. LIMIT                → stop after 2 rows
  */
Explanation (table transitions & key points)
SELECT article_id, title, published_at FROM articles WHERE (published_at, article_id) < (TIMESTAMP '2026-05-05 13:00', 5) ORDER BY published_at DESC, article_id DESC LIMIT 2;
LEGEND
Rows read / loaded
1. Source table — Paginate the newest-first list two rows at a time
FROM articles (newest first = published_at DESC, article_id DESC)The newest-first order is 8→7→6→5→4→3→2→1. Ids 5 and 6 tie on timestamp, so article_id DESC decides their order. Page 1=8,7 and page 2=6,5 have already been shown.
1 / 5
Orderarticle_idtitlepublished_at
18Announcement2026-05-07 15:00
27Summer feature2026-05-06 14:00
36Engineering blog2026-05-05 13:00
45Careers2026-05-05 13:00
54Interview article2026-05-04 12:00
63Incident report2026-05-03 11:00
72New feature introduction2026-05-02 10:00
81Spring feature2026-05-01 09:00
8 rows (the actual table has 1 million rows)—the last row on the previous page is id=5
LEARNING POINTS
OFFSET cost is proportional to the number of discarded rows: LIMIT 20 OFFSET 100000 reads 100,020 rows and throws away 100,000. Page 1 is fast, then the list slows linearly with depth—the classic “it was fine at first, then the list became heavy as data grew” incident. Inserts and deletes while displaying the list also shift rows, causing duplicate displays and omissions. Deep-page OFFSET is structurally unfavorable for both performance and correctness.
Keyset cuts pages by value, not position: OFFSET specifies a position (“which row from the beginning”), whereas keyset specifies a value (“after this key”). A value condition lets the index reach the continuation in one seek, bringing together the previously learned “ORDER BY and indexes” and “stop at Top-N.” The rules are to align the cursor condition and ORDER BY on the same columns and directions, and to include a unique key last in the ordering.
Row-value comparison is the readable form of an OR expansion: (a, b) < (x, y) is the same lexicographic comparison as a < x OR (a = x AND b < y). A hand-written OR expansion works, but row-value syntax states the intent more clearly and is easier for the planner to treat as a range condition on a composite index. The way id=6 (a same-time tie) is correctly excluded in the second-column comparison is exactly why the unique key belongs in the cursor.
ANTI-PATTERNS
Keep OFFSET just to support “jump to page 123”: Directly jumping to an arbitrary page is a weak spot of keyset pagination, but the UI requirement itself is worth questioning. Real users mostly use “next/previous” and search filters. Keyset handles infinite scroll and next/previous navigation, and total counts are often sufficiently displayed as an estimate or “100+” rather than a full COUNT.
Let ORDER BY and the cursor condition drift apart: With ORDER BY published_at DESC but only article_id < ? in the cursor—or without the tie-breaker column in the cursor—this mismatch causes duplicate or missing rows when timestamps tie. Make “all ordering columns, in the same directions, compared as a tuple” a mechanical checklist.
Practical column: Cursor-based pagination is a common language of API design
The reason major public APIs overwhelmingly use cursor pagination is that it puts the keyset pattern from this question directly into HTTP. The server encodes the last row's key in an opaque token (such as base64), returns it as next_cursor, and the client includes it in the next request—a thin, fast design that maps directly to a row-value comparison in the database. Put every ordering key into the cursor, and invalidate the cursor when the sort changes. Q9 asks an even smaller question than pagination: what is the shortest path when you only need to know whether something exists?
QUESTION 9
EXISTS vs COUNT — For Existence, Stop at the First Row Instead of Counting
EXISTSCOUNTEarly exitSemi Join
Background

When you want to know “does this user have an order?”, writing COUNT(*) > 0 makes the database count every row. What you actually want is a Boolean “yes/no,” not a count. EXISTS stops as soon as the inner query returns even one row—its internal representation is a Semi Join, the same tool as Q4 (avoid fan-out without DISTINCT). The difference between asking for a count and asking for existence can change the amount of scanning by a factor of one hundred or one thousand.

-- ✗ Cannot decide until the count is complete (worst case: read every row)
SELECT CASE WHEN (SELECT COUNT(*) FROM orders WHERE user_id = 42) > 0
            THEN 'yes' ELSE 'no' END;

-- ✓ Stop the moment one row is found (Semi Join)
SELECT EXISTS (SELECT 1 FROM orders WHERE user_id = 42);
The core idea: If you find yourself writing “count > 0,” that is a signal that you are asking for existence, not a count. EXISTS states the intent exactly and gives the planner the powerful hint that it may stop at the first row.
Problem

From users (100,000 actual rows) and orders (5 million actual rows, with an index on user_id), build a list of users who have at least one order and users with no orders, with an existence flag for each. Do not count orders per user for the judgment (the count is not needed and the scan would be wasteful). The output columns must be user_id, name, has_order ('yes' / 'no', user_id ascending).

Tables used
- users
user_idname
1Sato
2Suzuki
3Tanaka
4Ito
- orders (Sato/Tanaka have many orders, Suzuki has one, Ito has none)
order_iduser_id
1011
1021
1031
1042
1053
1063

※ Even if Sato has 1 million orders, this judgment should be settled the moment the first row is read.

Expected Output

Expected output (user_id ascending):

user_idnamehas_order
1Satoyes
2Suzukiyes
3Tanakayes
4Itono

The COUNT version counts all 1 million of Sato's orders before deciding '> 0'. The EXISTS version uses idx(user_id) and is decided the moment it finds one row.

Model Answer
SELECT   u.user_id, u.name,
         CASE WHEN EXISTS (
           SELECT 1 FROM orders o WHERE o.user_id = u.user_id  -- stop immediately when one row is found
         ) THEN 'yes' ELSE 'no' END AS has_order
FROM     users u
ORDER BY u.user_id;

-- ✗: CASE WHEN (SELECT COUNT(*) FROM orders WHERE user_id = u.user_id) > 0 …
--      → Count every order per user (run the idx(user_id) Range Scan to the end)

/*
  Logical evaluation order:
  1. FROM users        → scan
  2. EXISTS in SELECT  → stop after one row via the index (Semi Join)
  3. CASE              → convert TRUE/FALSE to 'yes'/'no'
  4. ORDER BY user_id  → sort ascending
  */
Explanation (table transitions & key points)
SELECT u.user_id, u.name, CASE WHEN EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.user_id ) THEN 'yes' ELSE 'no' END AS has_order FROM users u ORDER BY u.user_id;
LEGEND
Rows read / loaded
1. Source table — Decide whether each user has an order
FROM users u (query orders for each user)We want a Boolean “yes/no”: one match is enough to settle the answer. Even if Sato has 1 million orders, there is no reason to read the rest after finding the first one.
1 / 5
user_idnameOrder count (reference)
1SatoMany (up to 1 million in the worst case)
2Suzuki1 row
3TanakaMany
4Ito0 rows
4 rows (actual users table: 100,000 rows; orders: 5 million rows)
LEARNING POINTS
Communicating intent is the entry point to optimization: SQL is a declaration to the planner of what you want. COUNT reads as “I want the count,” so the planner knows it cannot answer until it counts to the end. EXISTS reads as “I want existence,” so it can choose an early-exit Semi Join. Even when two shapes return the same result, the execution strategy changes with how clearly the intent is stated—the same principle as Q4 (DISTINCT vs EXISTS).
SELECT 1 and SELECT * make no performance difference: Inside EXISTS, only whether a row is returned has meaning; the selected value is discarded. SELECT 1, SELECT *, and SELECT NULL all perform the same. The convention of writing SELECT 1 is a signal to the reader that column names and values do not matter; only existence is being asked. There is no need for aggregation or ORDER BY inside it.
Remember existence as the EXISTS / NOT EXISTS pair: Ask whether something “exists” with EXISTS (Semi Join), and whether it “does not exist” with NOT EXISTS (Anti Join, learned previously). With an index, both can stop when a row is found—or when the search proves there is none. IN / NOT IN are semantically close, but NOT IN has a NULL trap and does not communicate early-exit intent as clearly as EXISTS. EXISTS-family forms are the standard choice for existence checks.
ANTI-PATTERNS
“We do not use the count, but let us count it just in case”: The application checks if (count > 0), but the SQL returns COUNT—this is the most common production anti-pattern. If the count is used only for the judgment and never displayed, it can be replaced with EXISTS immediately. Ask: “Do we really use the counted number in the output?”
Write ORDER BY or LIMIT inside EXISTS: You may see EXISTS (SELECT 1 FROM orders ORDER BY ordered_at DESC LIMIT 1), but ordering and limiting inside EXISTS do not affect the result (only existence matters). It is redundant and can misleadingly suggest an intent such as “is there a latest order?” Keep the inside of EXISTS to the WHERE conditions.
Practical column: “Count,” “existence,” “maximum,” and “latest” need different tools
These questions look similar but are different: use COUNT when you output a count, EXISTS / NOT EXISTS when you ask whether something exists, MAX when you output the maximum value itself, and ROW_NUMBER (Q7) when you want all columns from the row holding the maximum. The misuse of COUNT in this question and the misuse of a MAX correlated subquery in Q7 have the same root: using an aggregation tool to check existence or identify a row. Make the shape of the question more precise and the planner can choose the shortest route. Q10 combines the five topics so far (types, windows, keyset pagination, EXISTS, partial indexes, and more) into one practical query.
QUESTION 10
Comprehensive Exercise — Combine Windows, Keyset Pagination, Partial Indexes, and Types in One Admin Query
ComprehensiveROW_NUMBERKeyset paginationPartial indexSargable
Background

As a capstone for this part, build a practical admin-screen query. “Among unprocessed tasks, show only the latest one for each assignee, then page through them newest-first with a cursor”—five foundational techniques divide the work cleanly.

① A partial index (Q5) indexes only the minority of 'pending' rows, ② ROW_NUMBER (Q7) selects the latest row per assignee, ③ keyset pagination (Q8) pages without OFFSET, ④ avoiding implicit type conversion (Q6) keeps the index usable, and ⑤ EXISTS (Q9) expresses the filter as existence. The Part 3 fundamentals—communicate intent / do not wrap columns in functions / operate on the set once / stop at Top-N—all crystallize in one query.

-- The overall shape (pseudocode)
WITH ranked AS (             -- ① latest one per assignee with ROW_NUMBER
  SELECT ..., ROW_NUMBER() OVER (PARTITION BY assignee_id ORDER BY created_at DESC, task_id DESC) AS rn
  FROM tasks WHERE status = 'pending'   -- ② shape that lets the partial index work (align the string type)
)
SELECT ... FROM ranked
WHERE rn = 1
  AND (created_at, task_id) < (?, ?)              -- ③ continue with keyset pagination
  AND EXISTS (SELECT 1 FROM task_tags ...)        -- ⑤ existence check with EXISTS
ORDER BY created_at DESC, task_id DESC LIMIT ?;
The goal: Each technique is a standalone tool, but practical queries benefit where multiple concerns overlap. The goal here is to internalize the order and division of labor when combining them.
Problem

Build the admin screen for a task-management SaaS showing the newest unprocessed task for each assignee, newest first. The requirements are:

  • Show only the latest pending task for each assignee (tie-breaker: task_id DESC)
  • Keep only tasks with the “important” tag (there is a tag = 'important' row in task_tags)
  • Use cursor pagination in newest-first order (created_at DESC, task_id DESC), two rows per page
  • Previous-page cursor: (created_at, task_id) = ('2026-06-04 11:00', 4) (retrieve the next page from this cursor position)
  • Do not use OFFSET; also provide a partial index to avoid scanning every table row (status is varchar)
Tables used
- tasks (status is varchar; 1 million actual rows; 'pending' is about 0.5%)
task_idassignee_idtitlestatuscreated_at
11Review invoicespending2026-06-08 10:00
21Organize meeting minutespending2026-06-05 09:00
32Hiring interviewpending2026-06-07 14:00
42Design reviewpending2026-06-04 11:00
53Audit responsepending2026-06-03 16:00
63Revise contractpending2026-06-02 09:00
74Order suppliespending2026-05-30 13:00
85Arrange cleaningdone2026-06-09 09:00
- task_tags (junction table)
task_idtag
1important
2internal
3important
4important
5important
6internal
7important

※ Latest pending per assignee: A1→task1, A2→task3, A3→task5, A4→task7. Filtering by important leaves 1, 3, 5, 7 (all four match). Newest first is 1→3→5→7. Page 1=1,3 / page 2=5,7, but the cursor (2026-06-04 11:00, 4) is placed at task=4 rather than on a page boundary.

Expected Output

Expected output (the next page from the cursor position, newest first):

task_idassignee_idtitlecreated_at
53Audit response2026-06-03 16:00
74Order supplies2026-05-30 13:00

Of the four targets (tasks 1, 3, 5, 7), tasks older than cursor (2026-06-04 11:00, 4) are 5 and 7. Display those two as the next page from the cursor position.

Model Answer
CREATE INDEX idx_tasks_pending_keyset                             -- Partial index: index only 'pending' rows in latest-per-assignee order (Q5 + Q7)
  ON tasks (assignee_id, created_at DESC, task_id DESC)
  WHERE status = 'pending';
CREATE INDEX idx_task_tags_task_tag ON task_tags (task_id, tag);  -- Supporting index: existence lookup for the important tag (Q9 EXISTS)

WITH ranked AS (
  SELECT task_id, assignee_id, title, created_at,
         ROW_NUMBER() OVER (PARTITION BY assignee_id                       -- ① one partition per assignee
                            ORDER BY created_at DESC, task_id DESC) AS rn  -- tie-breaker is required
  FROM   tasks
  WHERE  status = 'pending'                                              -- ② the partial index is implied (string-to-string comparison, Q6)
)
SELECT   task_id, assignee_id, title, created_at
FROM     ranked
WHERE    rn = 1                                                            -- keep only the latest row per assignee
  AND    (created_at, task_id) < (TIMESTAMP '2026-06-04 11:00', 4)       -- ③ keyset pagination (Q8)
  AND    EXISTS (                                                          -- ⑤ existence check (Q9)
           SELECT 1 FROM task_tags tt
           WHERE  tt.task_id = ranked.task_id AND tt.tag = 'important'
         )
ORDER BY created_at DESC, task_id DESC                                  -- same columns and directions as the cursor
LIMIT    2;

/*
  Logical evaluation order:
  1. CTE FROM/WHERE        → use the partial index for status='pending'
  2. CTE window            → partition and assign ROW_NUMBER (use per-assignee index order)
  3. Outer WHERE rn = 1    → keep the first row per assignee
  4. Keyset row-value comparison → seek toward older rows
  5. EXISTS                → check for important with one lookup (Semi Join)
  6. ORDER BY + LIMIT      → order the remaining candidates and stop at 2 rows
  */
Explanation (table transitions & key points)
CREATE INDEX idx_tasks_pending_keyset ON tasks (assignee_id, created_at DESC, task_id DESC) WHERE status = 'pending'; WITH ranked AS ( SELECT task_id, assignee_id, title, created_at, ROW_NUMBER() OVER (PARTITION BY assignee_id ORDER BY created_at DESC, task_id DESC) AS rn FROM tasks WHERE status = 'pending' ) SELECT task_id, assignee_id, title, created_at FROM ranked WHERE rn = 1 AND (created_at, task_id) < (TIMESTAMP '2026-06-04 11:00', 4) AND EXISTS ( SELECT 1 FROM task_tags tt WHERE tt.task_id = ranked.task_id AND tt.tag = 'important' ) ORDER BY created_at DESC, task_id DESC LIMIT 2;
LEGEND
Rows read / loaded
Excluded / hidden data
1. Raw data — 1 million tasks + task_tags (many-to-many)
FROM tasks (pending is about 0.5%; skewed distribution)Only about 0.5% of the 1 million rows are pending. At this scale, starting to read every row including done means losing immediately. task_tags is a many-to-many junction table (previously learned), a typical structure for managing the important flag in a separate table.
1 / 6
task_idassignee_idtitlestatuscreated_at
11Review invoicespending2026-06-08 10:00
21Organize meeting minutespending2026-06-05 09:00
32Hiring interviewpending2026-06-07 14:00
42Design reviewpending2026-06-04 11:00
53Audit responsepending2026-06-03 16:00
63Revise contractpending2026-06-02 09:00
74Order suppliespending2026-05-30 13:00
85Arrange cleaningdone2026-06-09 09:00
8 rows (actual table: 1 million rows; about 5,000 pending) + 7 task_tags rows
LEARNING POINTS
The five techniques own different layers: A partial index reduces the rows read (physical), aligning types is the precondition for the index (language), ROW_NUMBER is the logical row filter (set operation), EXISTS is the intent to stop inside the lookup (execution strategy), and keyset pagination is the API design of paging by value instead of position. Because the layers differ, they do not collide; they speed up the query multiplicatively rather than by simple addition.
Express query requirements directly in index order: The order (assignee_id, created_at DESC, task_id DESC) in this question directly expresses the per-assignee PARTITION BY + ORDER BY for the window. The global order (created_at DESC, task_id DESC) has no assignee key, so it is a different ordering and is applied after reducing to the latest row per assignee. Do not assume that one index must serve every ordering requirement; design it by explaining the trade-offs among the required sequences. An index is not just a set of columns but an ordered sequence of columns.
Use EXPLAIN to check three things you want to avoid: In a well-shaped query, EXPLAIN ANALYZE should show (1) no huge Seq Scan (the partial index and implication avoid reading non-pending rows), (2) no large sort before the window (the per-assignee index order is used), and (3) no SubPlan with huge loops (EXISTS becomes a Semi Join and stops early). The final candidate ORDER BY remains, so verify the candidate count and Sort size in the actual EXPLAIN.
ANTI-PATTERNS
Solve each concern separately until the query swells: “Latest row with GROUP BY + MAX, filtering with JOIN + DISTINCT, pagination with OFFSET, and just in case UPPER(status) = 'PENDING'”—stacking isolated solutions produces function wrappers + fan-out + full scans + fabricated rows all at once. The right answer is to design one route as a data flow, not merely assemble disconnected parts.
Add indexes and leave the planner to figure it out: Multiplying single-column indexes such as (assignee_id), (created_at), (status), and (task_id, tag) pays storage and write costs but often leads only to a mediocre BitmapAnd plan for a composite requirement. A composite index designed backward from the query requirements is usually faster, lighter, and easier to read.
Practical column: Part 3 complete — a map for SQL that is fast, correct, and readable
Across Part 3, we acquired ten tools: fold N+1 into JOIN + aggregation (Q1), three-valued NULL logic (Q2), make date filters Sargable with half-open intervals (Q3), do not remove duplicates with DISTINCT; do not create them with EXISTS (Q4), index only the minority of a skewed column with a partial index (Q5), align types and move the literal side, not the column (Q6), use the four-part ROW_NUMBER pattern for greatest-n-per-group (Q7), use keyset row-value comparisons for value-based deep pagination (Q8), consider EXISTS when you want to write “count > 0” (Q9), and design a query that combines individual techniques (Q10). The spine is to reduce both execution count × cost per execution, avoid wrapping columns in functions or implicit casts, keep intermediate results small, and communicate intent precisely to the planner. Now that we have the map for the whole fundamentals section, the next skills are reading actual EXPLAIN output and understanding planner internals such as join algorithms, statistics, and parallel execution. Those are covered in the sequel. Well done.