API Pagination Patterns: Offset, Cursor, and Keyset
Choose the right pagination strategy for your API and avoid common pitfalls.
March 1, 2026 · 7 min · 1286 words · Rob Washington
Table of Contents
Every API that returns lists needs pagination. Without it, a request for “all users” could return millions of rows, crushing your database and timing out the client. But pagination has tradeoffs—and choosing wrong can hurt performance or cause data inconsistencies.
The classic approach. Simple to implement, simple to understand:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@app.get("/users")deflist_users(limit:int=20,offset:int=0):users=db.query("SELECT * FROM users ORDER BY id LIMIT %s OFFSET %s",(limit,offset))total=db.query("SELECT COUNT(*) FROM users")[0][0]return{"data":users,"pagination":{"limit":limit,"offset":offset,"total":total}}
Pros:
Easy to implement
Clients can jump to any page
Total count is available
Cons:
Slow at scale:OFFSET 1000000 makes the database scan 1,000,000 rows before returning 20
Inconsistent: If rows are inserted/deleted between requests, you’ll skip or duplicate items
COUNT is expensive: On large tables, COUNT(*) can be slow
Use when: Small datasets, admin interfaces, or when random page access is required.
importbase64importjsondefencode_cursor(user_id,created_at):data={"id":user_id,"created_at":created_at.isoformat()}returnbase64.urlsafe_b64encode(json.dumps(data).encode()).decode()defdecode_cursor(cursor):data=json.loads(base64.urlsafe_b64decode(cursor))returndata["id"],data["created_at"]@app.get("/users")deflist_users(limit:int=20,after:str=None):ifafter:last_id,last_created=decode_cursor(after)users=db.query("""
SELECT * FROM users
WHERE (created_at, id) > (%s, %s)
ORDER BY created_at, id
LIMIT %s """,(last_created,last_id,limit+1))# Fetch one extra to check if more existelse:users=db.query("""
SELECT * FROM users
ORDER BY created_at, id
LIMIT %s """,(limit+1,))has_more=len(users)>limitusers=users[:limit]return{"data":users,"pagination":{"next_cursor":encode_cursor(users[-1].id,users[-1].created_at)ifhas_moreelseNone,"has_more":has_more}}
Pros:
Fast at any depth: No scanning past rows—database seeks directly to position
Consistent: Insertions/deletions don’t cause skips or duplicates
Scalable: Performance is constant regardless of page number
@app.get("/users")deflist_users(limit:int=20,created_after:datetime=None,id_after:int=None):ifcreated_afterandid_after:users=db.query("""
SELECT * FROM users
WHERE (created_at, id) > (%s, %s)
ORDER BY created_at, id
LIMIT %s """,(created_after,id_after,limit))else:users=db.query("""
SELECT * FROM users
ORDER BY created_at, id
LIMIT %s """,(limit,))return{"data":users}
Pros:
Same performance benefits as cursor pagination
Transparent—clients see the actual values
Clients can construct their own “cursors”
Cons:
Exposes internal sort keys
More complex for multi-column sorts
Clients might manipulate values incorrectly
Use when: You trust your clients (internal APIs, server-to-server).
-- For offset pagination (still slow, but less slow)
CREATEINDEXidx_users_idONusers(id);-- For cursor/keyset pagination (fast seeks)
CREATEINDEXidx_users_created_idONusers(created_at,id);-- For filtered + paginated queries
CREATEINDEXidx_users_status_created_idONusers(status,created_at,id);
The query must match the index order. If you’re sorting by created_at, id, your WHERE clause must use the same columns in the same order.
Default recommendation: Start with cursor pagination. It handles scale and consistency from day one. Only use offset if you truly need random page access—and be prepared for the performance cost.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.