Syntax Cache
BlogMethodFeaturesHow It WorksBuild a Game
  1. Home
  2. SQL
  3. SQL Sorting & Pagination Practice
SQL12 exercises

SQL Sorting & Pagination Practice

Drill ORDER BY, LIMIT/OFFSET, cursor/keyset pagination (seek method), NULL ordering, and tie-breakers to prevent duplicate/skip bugs in paginated results.

Common ErrorsQuick ReferencePractice
Warm-up1 / 2

Can you write this from memory?

Select all products sorted by price from highest to lowest.

On this page
  1. 1The golden rule: deterministic ORDER BY
  2. 2OFFSET vs keyset pagination
  3. OFFSET pagination (simple but slow)
  4. Keyset pagination (fast for any depth)
  5. Keyset pagination for ASC order
  6. MySQL keyset (expanded form)
  7. 3NULL ordering
  8. NULLS FIRST/LAST syntax (PostgreSQL, SQLite 3.30+)
  9. Portable workaround (works everywhere)
  10. 4Indexing for ORDER BY performance
  11. Index direction matters
  12. When indexes don't help
  13. 5Custom sort order with CASE
  14. Handling special values
  15. 6FETCH FIRST (SQL standard syntax)
  16. 7Debugging checklist: "Why are my pages wrong?"
  17. 8References
The golden rule: deterministic ORDER BYOFFSET vs keyset paginationNULL orderingIndexing for ORDER BY performanceCustom sort order with CASEFETCH FIRST (SQL standard syntax)Debugging checklist: "Why are my pages wrong?"References

A common SQL pagination bug: you ORDER BY a non-unique column, and users see duplicates or skipped rows when data changes between pages.

LIMIT requires a deterministic ORDER BY. OFFSET gets slow for deep pages. NULLs sort differently across databases. Ties in ORDER BY make pagination skip or duplicate rows when new data arrives.

Always add a unique tie-breaker column (like id) to ORDER BY when using LIMIT.

These exercises drill sorting and pagination patterns until deterministic queries and keyset pagination are automatic.

Related SQL Topics
SQL Filtering: WHERE, LIKE, IN, BETWEEN, NULLSQL Aggregation: GROUP BY, HAVING, COUNT, SUM, AVGSQL Window Functions

Always add a unique tie-breaker column (like id) to ORDER BY when using LIMIT. Without it, ties cause duplicate or skipped rows across pages. ORDER BY typically follows a WHERE clause filter that narrows results before sorting.

LIMIT requires ORDER BY to include a unique column.

PostgreSQL docs explicitly state: "If the ORDER BY does not uniquely order the result set, you may receive an unpredictable subset of rows."

-- BAD: Ties in created_at cause unpredictable results
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;

-- GOOD: id breaks ties deterministically
SELECT * FROM users
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;

Why this matters: If two users have the same created_at, their order is undefined. When new data is inserted or deleted, rows shift between pages. Users see duplicates on page 3 that were already on page 2, or skip rows entirely.


Ready to practice?

Start practicing SQL Sorting & Pagination with spaced repetition

OFFSET scans and discards rows—page 50 is 50x slower than page 1. Keyset pagination uses the last row's values as a cursor, making any page equally fast.

OFFSET pagination (simple but slow)

-- Page 1
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 20;

-- Page 2
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 20;

-- Page 50
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 980;

Problem: OFFSET 980 means the database finds 1,000 rows, sorts them, discards 980, and returns 20. The deeper the page, the slower it gets—OFFSET cost scales linearly with the page number. This is why the APIs for Slack, Stripe, and GitHub all use cursor-based pagination instead of OFFSET for their public endpoints.

Keyset pagination (fast for any depth)

-- Page 1
SELECT * FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Next page: use last row's values as cursor
SELECT * FROM posts
WHERE (created_at, id) < ('2024-01-15 10:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Why it's fast: The database uses an index to seek directly to the cursor position. No rows are scanned and discarded. Page 50 is as fast as page 1. The PostgreSQL wiki's pagination guide explicitly recommends keyset pagination over OFFSET for large datasets.

Trade-off: You can't jump to arbitrary pages. You can only go "next" or "previous" from a known position. This is fine for feeds, infinite scroll, and "Load More" buttons.

Keyset pagination for ASC order

-- Ascending order: use > instead of <
SELECT * FROM posts
WHERE (created_at, id) > (:cursor_created_at, :cursor_id)
ORDER BY created_at ASC, id ASC
LIMIT 20;

MySQL keyset (expanded form)

MySQL 5.7+ supports tuple comparison, but older versions require manual expansion. The expanded form also works if the optimizer struggles with tuple comparisons:

-- Equivalent to WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
SELECT * FROM posts
WHERE created_at < :cursor_created_at
   OR (created_at = :cursor_created_at AND id < :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Default NULL sort position varies by database. Use NULLS FIRST/LAST (PostgreSQL, SQLite 3.30+) or the portable workaround: ORDER BY (col IS NULL), col.

By default, NULL sorts high in ASC order and low in DESC order—but this varies by database.

DatabaseNULL in ASCNULL in DESCNULLS FIRST/LAST
PostgreSQLLastFirst✅ Supported
SQLiteFirstLast✅ Supported (3.30+)
MySQLFirstLast❌ Not supported
SQL ServerFirstLast❌ Not supported

NULLS FIRST/LAST syntax (PostgreSQL, SQLite 3.30+)

-- Put unshipped orders last
SELECT * FROM orders
ORDER BY shipped_at ASC NULLS LAST;

-- Put unshipped orders first
SELECT * FROM orders
ORDER BY shipped_at DESC NULLS FIRST;

Portable workaround (works everywhere)

-- NULLs last: (column IS NULL) returns 0 for non-NULL, 1 for NULL
SELECT * FROM orders
ORDER BY (shipped_at IS NULL) ASC, shipped_at ASC;

-- NULLs first
SELECT * FROM orders
ORDER BY (shipped_at IS NULL) DESC, shipped_at ASC;

How it works: (shipped_at IS NULL) returns FALSE (0) for non-NULL values and TRUE (1) for NULL. Sorting by this expression first groups NULLs together, then the second column orders within each group.


If your ORDER BY matches an index, the database can read rows in sorted order without a separate sort step.

-- Create an index that matches your ORDER BY
CREATE INDEX idx_users_created_at_id
ON users (created_at DESC, id DESC);

-- This query can now use the index for ordering
SELECT * FROM users
ORDER BY created_at DESC, id DESC
LIMIT 20;

Index direction matters

PostgreSQL allows specifying ASC/DESC and NULLS FIRST/LAST per column in indexes:

-- Match your query's exact ordering
CREATE INDEX idx_orders_status
ON orders (shipped_at ASC NULLS LAST, id ASC);

MySQL 8.0+, SQLite, and PostgreSQL all support specifying ASC/DESC in indexes. Older MySQL versions (5.7 and earlier) ignored index direction and always scanned forward or backward as needed.

When indexes don't help

  • Small result sets: Sorting a few hundred rows is cheap; the optimizer may skip the index.
  • Functions on columns: ORDER BY LOWER(name) can't use an index on name. Create an expression index: CREATE INDEX ON users (LOWER(name)).
  • Missing WHERE clause: If you're ordering the entire table, the index scan may not help.

ORDER BY is also critical inside window function OVER clauses and for deterministic results when aggregating sorted data.


Map values to sort priorities:

-- Priority-based ordering
SELECT * FROM tickets
ORDER BY
  CASE status
    WHEN 'urgent' THEN 1
    WHEN 'high' THEN 2
    WHEN 'medium' THEN 3
    WHEN 'low' THEN 4
    ELSE 5
  END,
  created_at DESC,
  id DESC;

Handling special values

-- "Featured" items first, then by date
SELECT * FROM products
ORDER BY
  CASE WHEN is_featured THEN 0 ELSE 1 END,
  created_at DESC,
  id DESC;

-- NULL priorities treated as lowest
SELECT * FROM tasks
ORDER BY COALESCE(priority, 999) ASC, id ASC;

The SQL standard uses OFFSET ... FETCH instead of LIMIT:

-- SQL standard syntax
SELECT * FROM products
ORDER BY price DESC, id DESC
OFFSET 20 ROWS
FETCH FIRST 10 ROWS ONLY;

-- Equivalent to
SELECT * FROM products
ORDER BY price DESC, id DESC
LIMIT 10 OFFSET 20;
DatabaseLIMITFETCH FIRST
PostgreSQL✅✅
SQLite✅❌
MySQL✅❌
SQL Server❌✅

For maximum portability across MySQL and SQL Server, you'd need to use different syntax for each.


  1. Duplicate rows across pages? Missing unique tie-breaker in ORDER BY. Add id or another unique column.

  2. Skipped rows? Same cause—ties plus data changes between page loads. Use keyset pagination or add tie-breaker.

  3. Random order on refresh? Missing ORDER BY entirely, or ORDER BY on a column with all identical values.

  4. Deep pages are slow? OFFSET scans and discards. Switch to keyset pagination or add an appropriate index.

  5. NULLs in wrong position? Default NULL ordering varies by database. Use NULLS FIRST/LAST or the portable workaround.

  6. ORDER BY not using index? Check that index column order matches ORDER BY, including direction.


  • PostgreSQL: LIMIT and OFFSET
  • PostgreSQL: Index Ordering (ASC/DESC, NULLS FIRST/LAST)
  • MySQL: LIMIT Clause
  • MySQL: ORDER BY Optimization
  • Use The Index, Luke: Pagination
  • SQLite: NULL Handling in ORDER BY

When to Use SQL Sorting & Pagination

  • Order results for user-facing lists or reports.
  • Paginate result sets with LIMIT/OFFSET or keyset pagination.
  • Control NULL placement with NULLS FIRST/LAST (or the portable workaround).
  • Build feeds, infinite scroll, or "load more" patterns.

Check Your Understanding: SQL Sorting & Pagination

Prompt

Return the newest 20 users after skipping the first 40.

What a strong answer looks like

Always include a tie-breaker for deterministic results: ```sql -- OFFSET pagination with tie-breaker SELECT * FROM users ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 40; ``` For very large offsets, mention keyset pagination (seek method) as the more efficient alternative: ```sql -- Keyset pagination (no OFFSET) SELECT * FROM users WHERE (created_at, id) < (:cursor_created_at, :cursor_id) ORDER BY created_at DESC, id DESC LIMIT 20; ``` Keyset uses the last row's values as a cursor, so the DB can seek via an index instead of scanning and discarding OFFSET rows.

What You'll Practice: SQL Sorting & Pagination

ORDER BY single and multiple columnsASC and DESC orderingDeterministic ORDER BY with unique tie-breakersLIMIT and OFFSET for paginationFETCH FIRST for standard SQLKeyset pagination (seek method)NULLS FIRST and NULLS LASTPortable NULLS LAST workaroundCustom sort order with CASEIndexing for ORDER BY performance

Common SQL Sorting & Pagination Pitfalls

  • ORDER BY without tie-breaker causes duplicates/skips on paginated data
  • OFFSET is slow for deep pagination (scans and discards rows)
  • Missing ORDER BY with LIMIT gives unpredictable results
  • NULL sorting varies by database (NULLS FIRST/LAST support differs)
  • Non-deterministic ORDER BY breaks "page 2 after refresh"
  • FETCH FIRST not supported in MySQL (use LIMIT)
  • Functions in ORDER BY disable index usage

SQL Sorting & Pagination FAQ

Why do I see duplicates or skipped rows when paging?

Ties in your ORDER BY column combined with data changes between page loads. If two rows have the same created_at, their relative order is undefined. When new data is inserted, rows shift pages. Fix: always add a unique tie-breaker column (e.g., id) to ORDER BY.

Why is LIMIT without ORDER BY random?

Without ORDER BY, the database can return rows in any order it wants. The result depends on physical storage, indexes, and parallel execution. PostgreSQL docs explicitly warn that LIMIT with no ORDER BY gives an "unpredictable subset" of rows.

Why is OFFSET slow for deep pages?

OFFSET 10000 means the database must find, sort, and discard 10,000 rows before returning your 20. The deeper the page, the more wasted work. Keyset pagination (seek method) uses the last row's values as a cursor, so the DB can use an index to jump directly to the right position—no matter how deep the page.

How do I do cursor/keyset pagination?

Instead of OFFSET, use the last row's ORDER BY values as a cursor. For 'ORDER BY created_at DESC, id DESC', the next page query is: WHERE (created_at, id) < (:cursor_created_at, :cursor_id). Tuple comparison works in PostgreSQL and MySQL 5.7+. For older MySQL or optimizer edge cases, use the expanded form: WHERE created_at < :cursor OR (created_at = :cursor_created_at AND id < :cursor_id).

How do I sort NULLs last in MySQL?

MySQL doesn't support NULLS LAST syntax. Use a computed column: ORDER BY (column IS NULL), column ASC. The boolean expression sorts FALSE (0) before TRUE (1), putting non-NULL values first.

Does SQLite support NULLS LAST?

Yes, since SQLite 3.30.0 (October 2019). Older versions need the workaround: ORDER BY (column IS NULL), column ASC.

Why is ORDER BY slow?

If there's no index matching your ORDER BY columns, the database must sort the entire result set in memory (or on disk for large sets). Fix: create an index that matches your ORDER BY order, including ASC/DESC and NULLS FIRST/LAST in PostgreSQL.

What is FETCH FIRST and when should I use it?

FETCH FIRST n ROWS ONLY is the SQL standard alternative to LIMIT. PostgreSQL and SQLite support both; MySQL only supports LIMIT. Use LIMIT for portability unless you need SQL standard compliance.

How do I sort by a custom order (e.g., status priority)?

Use CASE in ORDER BY: ORDER BY CASE status WHEN 'urgent' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END, created_at DESC, id DESC. The CASE expression maps values to sort priorities.

Can ORDER BY use an expression?

Yes, you can ORDER BY expressions: ORDER BY ABS(price - 100), LENGTH(name). PostgreSQL allows any expression. Just remember that expressions typically can't use indexes unless you create an expression index.

SQL Sorting & Pagination Syntax Quick Reference

Deterministic ORDER BY (tie-breaker)
-- Always add a unique column to prevent ties
SELECT * FROM users
ORDER BY created_at DESC, id DESC;
OFFSET pagination
-- Page 3 (rows 41-60) with deterministic ordering
SELECT * FROM users
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 40;
Keyset pagination (first page)
-- Initial page: no cursor needed
SELECT *
FROM users
ORDER BY created_at DESC, id DESC
LIMIT 20;
Keyset pagination (next page)
-- Use last row's values as cursor (PostgreSQL tuple syntax)
SELECT *
FROM users
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Keyset pagination (expanded form)
-- Works in all databases; useful if optimizer struggles with tuples
SELECT *
FROM users
WHERE created_at < :cursor_created_at
   OR (created_at = :cursor_created_at AND id < :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;
NULLS LAST (PostgreSQL/SQLite)
-- NULLs sort last in ASC order
SELECT * FROM orders
ORDER BY shipped_at ASC NULLS LAST;
NULLS LAST (portable workaround)
-- Works in MySQL and all databases
-- FALSE (0) sorts before TRUE (1)
SELECT * FROM orders
ORDER BY (shipped_at IS NULL) ASC, shipped_at ASC;
Custom sort order (CASE)
-- Sort by business priority, then by date
SELECT * FROM tickets
ORDER BY
  CASE status
    WHEN 'urgent' THEN 1
    WHEN 'high' THEN 2
    WHEN 'normal' THEN 3
    ELSE 4
  END,
  created_at DESC,
  id DESC;
FETCH FIRST (SQL standard)
-- Standard SQL alternative to LIMIT
SELECT * FROM products
ORDER BY price DESC, id DESC
OFFSET 20 ROWS
FETCH FIRST 10 ROWS ONLY;
Multiple columns with mixed order
-- Category ascending, price descending within category
SELECT * FROM products
ORDER BY category ASC, price DESC, id ASC;
ORDER BY expression
-- Sort by distance from target price
SELECT *, ABS(price - 100) AS price_diff
FROM products
ORDER BY ABS(price - 100) ASC, id ASC;
ORDER BY with COALESCE
-- Treat NULL priorities as lowest priority
SELECT * FROM tasks
ORDER BY COALESCE(priority, 999) ASC, id ASC;

SQL Sorting & Pagination Sample Exercises

Example 1Difficulty: 1/5

Fill in the blank to sort products by name in ascending order (A to Z)

ASC
Example 2Difficulty: 1/5

What is the default sort direction when you write ORDER BY price without ASC or DESC?

ASC
Example 3Difficulty: 2/5

Complete the query to sort by category first, then by price within each category

price

+ 9 more exercises

Start practicing SQL Sorting & Pagination

Free daily exercises with spaced repetition. No credit card required.

← Back to SQL Syntax Practice
Syntax Cache

Build syntax muscle memory with spaced repetition.

Product

  • Pricing
  • Our Method
  • Daily Practice
  • Design Patterns
  • Interview Prep

Resources

  • Blog
  • Compare
  • Cheat Sheets
  • Vibe Coding
  • Muscle Memory

Languages

  • Python
  • JavaScript
  • TypeScript
  • Rust
  • SQL
  • GDScript

Legal

  • Terms
  • Privacy
  • Contact

© 2026 Syntax Cache

Cancel anytime in 2 clicks. Keep access until the end of your billing period.

No refunds for partial billing periods.