SQL ORDER BY and LIMIT: Top-N, OFFSET and Keyset Pagination
8 min readBytePatterns
SQL ORDER BY and LIMIT explained: why LIMIT without ORDER BY is random, how ties break pages, why OFFSET gets slower, and how keyset pagination fixes it.
ORDER BY and LIMIT look like the easiest clauses in SQL: sort, then keep a few. Their bugs hide in development. A page that repeats a row, a leaderboard that reshuffles on refresh, a list that slows with every page: each comes from a missing sort, a sort with ties, or an OFFSET that reads everything it skips. Every query below was run with Python's sqlite3 module (SQLite 3.37).
The problem it solves
Two everyday questions:
- Top N: the three fastest runners, the ten newest posts, the five largest orders. One sorted slice, one round trip.
- Pagination: page after page of the same sorted list, where page
kmust continue exactly where pagek - 1stopped: no gaps, no repeats.
Both rest on one fact: a SQL result has no order unless ORDER BY gives it one. Without it, rows come back in whatever order the access path produces, which can change when an index is added or the table grows.
The intuition
Sort, then cut. ORDER BY defines the sequence and LIMIT takes a prefix of it. Logically the sort runs after WHERE, grouping and SELECT, and the cut comes last, as the order of execution shows.
Make the order total. Rows that tie on the sort key have no defined relative order, which may differ between two runs of the same query. For pagination that means a row can appear on two pages or on none. Add a unique column as the last sort key: ORDER BY seconds, bib.
The engine does not have to sort everything. With a LIMIT, it can keep only the best N rows while it reads; PostgreSQL's plans call this a top-N heapsort. With an index already in sort order, it walks the index and stops after N rows.
OFFSET skips by reading. LIMIT 10 OFFSET 9000 still produces rows 1 to 9,000 and throws them away.
Keyset pagination remembers where you stopped. Ask for "rows after the last one I showed": WHERE id > :last_id ORDER BY id LIMIT 10. With an index on the sort key, that is a seek plus ten rows on any page. The cursor is the last row's full sort key, tie-breaker included.
Watch it run
The animation uses the lesson's runner table. With no ORDER BY, rows arrive in whatever order the engine found convenient, with no promise, so LIMIT 3 alone just cuts three arbitrary rows off an unordered pile. ORDER BY seconds ASC gives the pile a meaning, so the engine sorts it. Bruno's 8755 is the smallest elapsed time, so Bruno goes first; then Dara at 8890 and Chi at 9040, and Ada's 9120 lands last. Now LIMIT 3 means something: take the first three and stop reading. The podium board comes back in a single round trip, Bruno, Dara, Chi, and nobody printed the full field. Because of the LIMIT, the engine can often stop early instead of sorting the whole field. Page two is LIMIT 3 OFFSET 3: skip the podium and take the next slice of the same sort.
ORDER BY and LIMIT
Step 1 of 9
With no ORDER BY, rows arrive in whatever order the engine found convenient. No promise.
The same interactive animation as the lesson — step through it with the controls.
The code
The lesson's table plus Eun, who ties with Dara; the unique bib breaks the tie. The plan shows the sort, then shows it vanish once an index matches the ORDER BY:
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE runner (bib INTEGER PRIMARY KEY, name TEXT, seconds INTEGER);
INSERT INTO runner VALUES (14, 'Ada', 9120), (7, 'Bruno', 8755),
(22, 'Chi', 9040), (3, 'Dara', 8890), (9, 'Eun', 8890);
""")
podium = "SELECT name, seconds FROM runner ORDER BY seconds, bib LIMIT 3"
print(db.execute(podium).fetchall())
# [('Bruno', 8755), ('Dara', 8890), ('Eun', 8890)]
plan = lambda sql: [row[3] for row in db.execute("EXPLAIN QUERY PLAN " + sql)]
print(plan(podium))
# ['SCAN runner', 'USE TEMP B-TREE FOR ORDER BY']
db.execute("CREATE INDEX by_time ON runner(seconds, bib)")
print(plan(podium))
# ['SCAN runner USING INDEX by_time']
How much does OFFSET read? A function in the WHERE clause counts every row evaluated, on 10,000 posts. Both queries return the same page:
big = sqlite3.connect(":memory:")
big.execute("CREATE TABLE post (id INTEGER PRIMARY KEY, title TEXT)")
big.executemany("INSERT INTO post VALUES (?, ?)", ((i, "post %d" % i) for i in range(1, 10_001)))
touched = 0
def touch(_):
global touched
touched += 1
return 1
big.create_function("touch", 1, touch)
def rows_read(sql, *args):
global touched
touched = 0
page = big.execute(sql, args).fetchall()
return [r[0] for r in page][:3], touched
print(rows_read("SELECT id FROM post WHERE touch(id) ORDER BY id LIMIT 10 OFFSET 9000"))
# ([9001, 9002, 9003], 9010)
print(rows_read("SELECT id FROM post WHERE id > ? AND touch(id) ORDER BY id LIMIT 10", 9000))
# ([9001, 9002, 9003], 10)
Speed is not the only difference. Between two clicks, someone publishes post 9. The OFFSET query for page two shifts by one and shows post 6 again; the keyset query continues after the last id the reader saw:
feed = sqlite3.connect(":memory:")
feed.execute("CREATE TABLE post (id INTEGER PRIMARY KEY)")
feed.executemany("INSERT INTO post VALUES (?)", [(i,) for i in range(1, 9)])
newest = "SELECT id FROM post ORDER BY id DESC LIMIT 3"
page1 = [r[0] for r in feed.execute(newest)]
feed.execute("INSERT INTO post VALUES (9)") # someone posts between clicks
by_offset = [r[0] for r in feed.execute(newest + " OFFSET 3")]
by_key = [r[0] for r in feed.execute(
"SELECT id FROM post WHERE id < ? ORDER BY id DESC LIMIT 3", (page1[-1],))]
print(page1, by_offset, by_key)
# [8, 7, 6] [6, 5, 4] [5, 4, 3]
Checked on 300 seeded random tables with heavily tied times and random page sizes: keyset pages, using a row-value cursor on (seconds, bib), must concatenate to exactly the brute-force answer, every row sorted in Python, and must equal the OFFSET pages on the unchanged table:
import random
def keyset_pages(con, size):
pages, last = [], None
while True:
if last is None:
rows = con.execute("SELECT seconds, bib FROM r ORDER BY seconds, bib LIMIT ?", (size,)).fetchall()
else:
rows = con.execute("SELECT seconds, bib FROM r WHERE (seconds, bib) > (?, ?) "
"ORDER BY seconds, bib LIMIT ?", (*last, size)).fetchall()
if not rows:
return pages
pages.append(rows)
last = rows[-1] # the cursor: the last row's full sort key
def offset_pages(con, size):
pages, offset = [], 0
while True:
rows = con.execute("SELECT seconds, bib FROM r ORDER BY seconds, bib LIMIT ? OFFSET ?",
(size, offset)).fetchall()
if not rows:
return pages
pages.append(rows)
offset += size
random.seed(28)
ok = True
for _ in range(300):
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE r (bib INTEGER PRIMARY KEY, seconds INTEGER)")
bibs = random.sample(range(1, 1000), random.randint(0, 60))
rows = [(b, random.randint(1, 8)) for b in bibs] # many tied times
con.executemany("INSERT INTO r VALUES (?, ?)", rows)
size = random.randint(1, 7)
expected = sorted((s, b) for b, s in rows) # brute force: sort in Python
keyset = keyset_pages(con, size)
ok &= [row for page in keyset for row in page] == expected
ok &= keyset == offset_pages(con, size)
ok &= all(len(p) == size for p in keyset[:-1])
print(ok) # True
The complexity
For a table of n rows, a page of k and an offset of m:
- Top-N without a useful index: read all
nrows, keep the bestk: aboutO(n log k). - Top-N with an index in sort order:
O(log n + k), a seek andkrows. OFFSET mpagination:O(log n + m + k)even with the index, because the skipped rows are still produced.- Keyset pagination:
O(log n + k)on every page, given an index on the full sort key. The SQL cheat sheet putsLIMITandOFFSETlast in the evaluation order: they cut rows already produced.
Where it goes wrong
LIMITwithoutORDER BY. Arbitrary rows, stable only by accident.- Sorting on a non-unique column. Ties reorder between queries and pages repeat or skip rows. Add a unique tie-breaker.
- Keyset cursor on the sort column alone. With ties,
WHERE seconds > :lastskips the rest of a tied group. Compare the full key, as the row value above does, or spell it out withOR. - Deep
OFFSETpages. Fine for page 3, slow for page 3,000. Keyset gives up "jump to page 400", usually a fair trade. - NULLs in the sort key. Engines disagree on where NULLs sort: SQLite and MySQL put them first in ascending order, PostgreSQL last. Say
NULLS FIRSTorNULLS LASTwhere supported. - Assuming
LIMITis universal. As of September 2026, the standard form isFETCH FIRST n ROWS ONLY, and SQL Server also usesTOPorOFFSET ... FETCH.
When it shows up in interviews
In SQL rounds as "top N" questions, which become "top N per group" once a category is added; that needs window functions. In system design, every feed and list API needs pagination, and cursor-based pagination is the expected answer for the news feed. An index on (seconds, bib), as in composite indexes, is what makes the top-N and the keyset seek cheap.
How to say it in an interview
"LIMIT only means something after an ORDER BY, and the order has to be total, so I add a unique tie-breaker. With an index matching the sort, the engine reads the first index entries and stops. For pagination I avoid deep OFFSETs: the database still produces every skipped row, and rows shift when new data arrives. I use keyset pagination: the client sends back the last row's sort key, and the next page is WHERE the key is greater than that cursor, ORDER BY the key, LIMIT the page size. That is a seek plus one page, however deep."