REST API Pagination Patterns
Compare offset, cursor, and keyset pagination for REST APIs. Pick the right pattern for your data, scale, and client experience.
What you'll learn
- ✓Offset vs cursor vs keyset pagination
- ✓How to design a stable cursor
- ✓Pagination response shapes
- ✓Performance tradeoffs at scale
- ✓Pagination plus filtering and sorting
Prerequisites
- •Comfortable designing REST endpoints
What and Why
Returning a million rows in one response is a terrible idea. Pagination splits results into pages so clients can fetch them incrementally. Picking the right pagination strategy affects both performance and the user experience.
Mental Model
The three common patterns trade off simplicity and scale.
- Offset pagination uses
limitandoffset. Easy, but slow on big tables and unstable under writes. - Cursor pagination returns an opaque token pointing to the next batch. Stable and fast.
- Keyset pagination uses a known column like
idorcreated_atas the cursor. Even faster, less opaque.
Offset: ?limit=20&offset=10000 -> DB scans 10020 rows
Keyset: ?limit=20&after=12345 -> DB seeks to id > 12345 (index range)
Cursor: ?limit=20&cursor=eyJpZCI6MTIzfQ -> server decodes to keyset query Hands-on Example
Offset pagination is simple but does not scale.
from fastapi import FastAPI, Query
app = FastAPI()
@app.get("/posts")
def list_posts(limit: int = Query(20, le=100), offset: int = 0):
rows = db.execute(
"SELECT id, title FROM posts ORDER BY id LIMIT :limit OFFSET :offset",
{"limit": limit, "offset": offset},
).all()
total = db.execute("SELECT COUNT(*) FROM posts").scalar()
return {"items": rows, "total": total, "limit": limit, "offset": offset}
Keyset pagination scales much better.
@app.get("/posts/keyset")
def list_posts_keyset(limit: int = Query(20, le=100), after_id: int = 0):
rows = db.execute(
"SELECT id, title FROM posts WHERE id > :after ORDER BY id LIMIT :limit",
{"after": after_id, "limit": limit},
).all()
next_id = rows[-1]["id"] if rows else None
return {"items": rows, "next_after_id": next_id}
Cursor pagination hides the cursor format from clients, letting you change it later.
import base64, json
def encode_cursor(payload: dict) -> str:
return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode()
def decode_cursor(s: str) -> dict:
return json.loads(base64.urlsafe_b64decode(s.encode()))
@app.get("/posts/cursor")
def list_posts_cursor(limit: int = Query(20, le=100), cursor: str | None = None):
after_id = decode_cursor(cursor)["id"] if cursor else 0
rows = db.execute(
"SELECT id, title FROM posts WHERE id > :after ORDER BY id LIMIT :limit",
{"after": after_id, "limit": limit},
).all()
next_cursor = encode_cursor({"id": rows[-1]["id"]}) if rows else None
return {"items": rows, "next_cursor": next_cursor}
A common response envelope keeps clients consistent.
{
"items": [{"id": 1}, {"id": 2}],
"page_info": {
"next_cursor": "eyJpZCI6Mn0",
"has_next": true
}
}
Returning hypermedia links is also nice.
{
"items": [],
"links": {
"next": "/posts?cursor=eyJpZCI6Mn0",
"self": "/posts"
}
}
Common Pitfalls
- Using offset on tables with millions of rows. Performance falls off a cliff.
- Sorting on a non-unique column without a tiebreaker. Pages can duplicate or skip rows.
- Returning a total count on huge tables.
COUNT(*)can be expensive; consider exact-only-when-small. - Letting clients pick unbounded
limit. Always cap it server-side. - Coupling pagination state to mutable state. If rows get reordered between calls, clients see chaos.
Practical Tips
- Default to cursor or keyset pagination for new APIs. Reserve offset for admin-style endpoints where it is fine.
- Always sort by a stable, indexed column. Compose
(created_at, id)if you need both time and uniqueness. - Document
limitdefaults and maximums explicitly. - Treat cursors as opaque. Clients should never inspect or build them.
- Combine pagination with filtering and sorting in a single endpoint to keep the contract simple.
Wrap-up
Pagination is a small piece of the API surface that has outsized impact on performance and client UX. Offset is fine for tiny datasets. For anything that will grow, reach for keyset or opaque cursors backed by indexed columns. Cap limits, stabilize sort orders, and document the contract clearly, and your endpoints will hold up as your data scales.
Related articles
- REST APIs REST API Caching: ETags, Cache-Control, and CDN Patterns
Master HTTP caching for REST APIs. Learn ETags, Cache-Control headers, conditional requests, and CDN integration patterns with practical examples.
- REST APIs REST API Versioning Strategies
Compare URL, header, and content-type versioning for REST APIs. Learn when to bump versions and how to retire old ones without breaking clients.
- REST APIs REST API Authentication: API Keys, JWT, and OAuth 2.0
Learn the three most common REST API authentication methods. Compare API keys, JWT tokens, and OAuth 2.0 with working code examples and security best practices.
- REST APIs REST API Testing: Postman, curl, and Automated Tests
Learn to test REST APIs manually with Postman and curl, then automate with Jest and Supertest. Covers status codes, response validation, and CI integration.