Chapter 05 · Filtering, Sorting, and Limiting Results
LIMIT, OFFSET, FETCH, and Pagination Tradeoffs
Return manageable result windows while understanding syntax portability, performance costs, and pagination drift.
Learning outcomes
Result limiting is not merely presentation. It creates a contract about which ordered rows are returned, how clients move through a large result, and how concurrent changes affect repeated requests.
Use SQLite/PostgreSQL LIMIT and OFFSET correctly with deterministic ORDER BY.
Recognize FETCH FIRST/NEXT and SQL Server TOP portability alternatives.
Explain the performance and consistency costs of deep offset pagination.
Implement keyset pagination with a stable composite sort key.
LIMIT returns a result window
SELECT sale_id, customer_id, sold_atFROM saleORDER BY sold_at DESC, sale_id DESCLIMIT 5;The query first defines a deterministic sequence, then returns its first five rows. Without ORDER BY, “first” has no durable meaning.
OFFSET skips rows in the ordered result
SELECT sale_id, customer_id, sold_atFROM saleORDER BY sold_at DESC, sale_id DESCLIMIT 3 OFFSET 3;OFFSET 3 discards the first three rows of the ordered result and returns up to three rows after them.
| Page | LIMIT | OFFSET |
|---|---|---|
| 1 | 3 | 0 |
| 2 | 3 | 3 |
| 3 | 3 | 6 |
| n | page size | (n - 1) × page size |
Syntax varies across major engines
| Dialect | Typical syntax | Notes |
|---|---|---|
| SQLite | LIMIT 10 OFFSET 20 | Also accepts a comma form, but the keyword form is clearer |
| PostgreSQL | LIMIT 10 OFFSET 20 or FETCH FIRST 10 ROWS ONLY | Supports standard-style FETCH |
| MySQL | LIMIT 20, 10 or LIMIT 10 OFFSET 20 | Both forms are common |
| SQL Server | TOP (10) or OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY | OFFSET/FETCH requires ORDER BY |
| Oracle | OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY | Modern row-limiting clause |
SELECT sale_id, sold_atFROM saleORDER BY sold_at DESC, sale_id DESCOFFSET 20 ROWSFETCH NEXT 10 ROWS ONLY;SELECT TOP (10) sale_id, sold_atFROM saleORDER BY sold_at DESC, sale_id DESC;Deterministic order is mandatory for pagination
ORDER BY sold_at DESCLIMIT 3 OFFSET 3;Rows sharing sold_at can exchange positions. A row may appear on two pages or disappear between them even when the data is unchanged.
ORDER BY sold_at DESC, sale_id DESCLIMIT 3 OFFSET 3;Offset pagination becomes expensive
A large offset can force the DBMS to process many rows that the client never receives. Exact cost depends on indexes, filters, joins, and the execution plan.
SELECT sale_id, sold_atFROM saleORDER BY sold_at DESC, sale_id DESCLIMIT 25 OFFSET 100000;Offset pagination is often acceptable for small administrative lists and shallow pages. It is less suitable for very large, frequently changing feeds.
Concurrent changes cause pagination drift
Suppose page 1 is fetched, then a new row is inserted at the beginning of the sort order. When page 2 uses the original offset, one previously seen row may shift onto page 2 and another row may be pushed to page 3.
| Change between requests | Possible offset effect |
|---|---|
| New leading row inserted | Duplicate row across page boundaries |
| Leading row deleted | A row can be skipped |
| Sort-key value updated | Row can move to a different page |
| Tied rows reordered | Page membership can change without a unique tie-breaker |
Keyset pagination continues after the last key
Instead of counting and skipping earlier rows, the client sends the final sort key from the previous page.
SELECT sale_id, customer_id, sold_atFROM saleORDER BY sold_at DESC, sale_id DESCLIMIT 3;Assume the last row returned has sold_at = '2026-08-05 09:00:00' and sale_id = 107.
SELECT sale_id, customer_id, sold_atFROM saleWHERE sold_at < '2026-08-05 09:00:00' OR (sold_at = '2026-08-05 09:00:00' AND sale_id < 107)ORDER BY sold_at DESC, sale_id DESCLIMIT 3;The continuation predicate mirrors the composite ordering. For ascending order, the comparison directions reverse.
Row-value comparison can simplify the cursor
SELECT sale_id, customer_id, sold_atFROM saleWHERE (sold_at, sale_id) < ('2026-08-05 09:00:00', 107)ORDER BY sold_at DESC, sale_id DESCLIMIT 3;SQLite and PostgreSQL support row-value comparisons. Portability and NULL behavior require care; the expanded OR form is more universally understandable.
Offset versus keyset pagination
| Concern | Offset pagination | Keyset pagination |
|---|---|---|
| Jump to arbitrary page | Straightforward | Not naturally supported |
| Deep-page performance | Often degrades as offset grows | Usually continues from an indexed key |
| Concurrent inserts/deletes | Can drift | More stable relative to the cursor |
| Implementation | Simple page number | Requires encoded last-key state |
| Ordering requirement | Deterministic order still required | Deterministic, cursor-compatible order required |
| Best fit | Small lists, shallow navigation | Large feeds, timelines, infinite scroll |
Count queries are separate work
SELECT sale_id, customer_id, sold_atFROM saleWHERE customer_id = 1ORDER BY sold_at DESC, sale_id DESCLIMIT 10 OFFSET 0;SELECT COUNT(*) AS total_rowsFROM saleWHERE customer_id = 1;A total count may be expensive for large filtered datasets. Decide whether the interface truly needs an exact total, an estimate, or only a “has more” signal.
Fetch one extra row to detect “has more”
-- UI page size is 3; fetch 4 rows.SELECT sale_id, customer_id, sold_atFROM saleORDER BY sold_at DESC, sale_id DESCLIMIT 4;If four rows are returned, display the first three and use the fourth only to conclude that another page exists.
Practice lab
- Return the three newest sales deterministically.
- Return the second offset-based page with page size three.
- Write the equivalent standard-style OFFSET/FETCH form.
- Continue after the key
('2026-08-05 09:00:00', 107)with keyset pagination. - Return four rows so a three-row interface can detect another page.
SELECT sale_id, customer_id, sold_atFROM saleORDER BY sold_at DESC, sale_id DESCLIMIT 3;SELECT sale_id, customer_id, sold_atFROM saleORDER BY sold_at DESC, sale_id DESCLIMIT 3 OFFSET 3;-- Standard-style syntax for engines that support it:SELECT sale_id, customer_id, sold_atFROM saleORDER BY sold_at DESC, sale_id DESCOFFSET 3 ROWS FETCH NEXT 3 ROWS ONLY;SELECT sale_id, customer_id, sold_atFROM saleWHERE sold_at < '2026-08-05 09:00:00' OR (sold_at = '2026-08-05 09:00:00' AND sale_id < 107)ORDER BY sold_at DESC, sale_id DESCLIMIT 3;SELECT sale_id, customer_id, sold_atFROM saleORDER BY sold_at DESC, sale_id DESCLIMIT 4;Common failures
Limiting without ORDER BY
The selected subset is not stable.
Ordering by a non-unique key
Ties can migrate between pages.
Using deep offsets for large feeds
The server may repeatedly process and discard a large prefix.
Ignoring concurrent change
Offset pages can duplicate or skip rows as the dataset changes.
Exposing raw cursor data carelessly
Applications should validate and often encode cursor state rather than trusting arbitrary client predicates.
Chapter 5 summary
WHEREfilters rows through predicates that must evaluate TRUE.AND,OR, andNOTrequire explicit grouping and NULL-aware reasoning.IN,BETWEEN, andLIKEsimplify membership, range, and pattern requirements when edge cases are controlled.ORDER BYdefines row sequence; a unique tie-breaker makes it deterministic.LIMIT/OFFSETis simple, while keyset pagination scales and resists drift better for large changing feeds.
Chapter 6 introduces functions, NULL-handling expressions, CASE, and clean derived columns.