REST Versioning and Pagination
Two API-design questions interviewers nearly always ask. The “right” answer is mostly conventional — there are bad choices but few wrong ones.
Versioning — four common strategies
| Strategy | Example | Pro | Con |
|---|---|---|---|
| URL path | /api/v2/users |
explicit, cacheable, easy ops | URL changes break clients |
| Query param | /api/users?version=2 |
additive | mixed in with normal params |
| Custom header | X-API-Version: 2 |
URL stays stable | hidden, harder to test |
| Media type | Accept: application/vnd.example.v2+json |
“RESTful” | obscure for non-experts |
URL path is the pragmatic default. Stripe, GitHub, AWS APIs — most major public APIs use it. Easy to monitor, easy to route, easy to deprecate.
Media-type versioning is theoretically clean but ops-hostile. Most teams give up after one round of “why isn’t this working” caused by Accept header debugging.
When to bump the version
Versioning is for breaking changes. Don’t bump for:
- Adding a new endpoint.
- Adding a new optional field to a response.
- Adding a new optional request parameter.
- Loosening validation (accepting more input).
Do bump for:
- Removing a field.
- Renaming a field.
- Changing a field’s type or format.
- Tightening validation (rejecting previously-valid input).
- Changing default behavior.
- Changing status codes for the same condition.
The right strategy is rarely bump. Add new fields, deprecate old ones (don’t remove for ~6 months), let clients migrate at their pace.
Deprecation flow
Sunset: Wed, 01 Jan 2026 00:00:00 GMT
Deprecation: true
Link: <https://api.example.com/changelog#deprecated-x>; rel="sunset"
Send these headers on the old endpoint. Track which clients still call it (by API key, user-agent). Communicate the sunset date. Remove only when no traffic remains.
For breaking changes that can’t be additive: ship v2 alongside v1, freeze v1, deprecate, eventually remove.
Pagination — three styles
| Style | Query | Best for |
|---|---|---|
| Offset / page | ?page=3&page_size=50 |
small bounded datasets, admin UIs |
| Cursor / keyset | ?cursor=eyJpZCI6MTAwfQ== |
large datasets, real-time feeds |
| Time-window | ?since=2024-01-01&until=2024-01-31 |
time-series, audit logs |
Page-based
GET /api/users?page=3&page_size=50
{
"data": [...],
"meta": {
"page": 3,
"page_size": 50,
"total": 1234,
"total_pages": 25
}
}
Pros: easy to implement, easy to “jump to page N.” Cons: OFFSET 5000 LIMIT 50 makes the DB scan + discard 5000 rows. Slow at depth. Plus COUNT(*) for total is itself slow on big tables.
Cursor-based
GET /api/users?cursor=eyJpZCI6MTAwfQ&limit=50
{
"data": [...],
"meta": {
"next_cursor": "eyJpZCI6MTUwfQ==",
"has_more": true
}
}
The cursor is an opaque token encoding the last-seen sort key (often base64-encoded JSON). The next query becomes:
SELECT * FROM users WHERE id > 100 ORDER BY id LIMIT 51
Constant-time at any depth. Doesn’t drift if rows are inserted between page loads.
Trade-offs:
- No “jump to page 10.”
- Doesn’t expose total count.
- Need a unique, monotonic sort key (typically
idorcreated_at + idfor tie-breaking).
For feeds, infinite scroll, large datasets — cursor wins.
Time-window
For time-series and append-only data:
GET /api/events?since=2024-01-15T00:00:00Z&limit=1000
Combined with cursor or trailing-ID for within-window pagination.
Pagination response shape
The bikeshed: where to put metadata?
{
"data": [...],
"meta": {"next_cursor": "...", "has_more": true}
}
vs envelope with links (HATEOAS-ish):
{
"data": [...],
"links": {
"self": "/api/users?cursor=...",
"next": "/api/users?cursor=...",
"prev": null
}
}
vs Link header (RFC 5988):
Link: </api/users?cursor=abc>; rel="next", </api/users?cursor=xyz>; rel="prev"
GitHub’s API uses Link headers. Many modern APIs (Stripe, Twilio) use a data + meta envelope. Either is fine — be consistent.
Page size limits
GET /api/users?page_size=1000000 # DoS via memory exhaustion
Always cap. Common default 25–100, max 1000. Reject requests above the cap:
HTTP/1.1 400 Bad Request
{"error": "page_size must be ≤ 1000"}
Or silently clamp:
page_size = min(int(request.GET.get("page_size", 25)), 1000)
Pick one and document it. Silent clamping is more lenient; explicit rejection forces clients to know.
Filtering, sorting, fields selection
These conventions are essentially “URL query language”:
GET /api/users?status=active&role=admin&sort=-created_at&fields=id,name,email
| Param | Meaning |
|---|---|
status=active |
equality filter |
created_at__gte=2024-01-01 |
range filter (Django-style) |
sort=-created_at |
sort by created_at desc |
fields=id,name |
sparse fieldsets — return only these fields |
For complex filtering, some teams use JSON:API spec:
GET /api/users?filter[status]=active&filter[role]=admin
Or RSQL/FIQL:
GET /api/users?filter=status==active;role==admin
Pick a convention; document it. The frontend team thanks you.
Common pitfalls
- Sort field not indexed —
sort=oldest_unindexed_columndoes a full table scan. sort=__all__on user-controllable fields — clients sort by sensitive columns or hit query planner edges. Allowlist.- Inconsistent pagination across endpoints — one uses
page, anotheroffset, another cursor. Standardize. - Returning total count on cursor pagination — requires the same expensive
COUNT(*)you tried to avoid. Drop it. - Cursor pagination ordered by non-unique column — ties cause skipped or duplicated rows. Always add
idas tiebreaker.
Common interview confusions
- “URL path versioning is RESTful.” — depending on whom you ask, none of them are pure REST. Pragmatism wins; URL path is most common.
- “Cursor pagination is always better.” — for “show me page 7 of search results,” cursor doesn’t fit. For feeds and big lists, cursor wins.
- “You need a version for every breaking change.” — many “breaking changes” can be made additive: add a new field, mark the old one deprecated, remove later.
Interview angle
- “How do you version a REST API?” — URL path is most common (
/api/v2/users). Header and media-type alternatives exist but are less popular. Avoid versioning when you can make changes additive. - “When does a change require a new version?” — breaking changes: removing/renaming fields, changing types, tightening validation. Additive changes (new fields, new endpoints, optional params) don’t.
- “Page-based vs cursor pagination — when each?” — page-based for small bounded data and admin UIs (easy to “jump”). Cursor for large datasets and feeds (constant-time at depth, doesn’t drift on inserts). Time-window for time-series.
- “Why is
OFFSET 100000 LIMIT 50slow?” — Postgres still scans 100050 rows to discard the first 100000. Cursor pagination usesWHERE id > last_seen_idagainst an indexed column. - “How do you handle deprecation?” —
SunsetandDeprecationHTTP headers on the old endpoint, communicate timeline to clients, track who still calls it, remove only when traffic ceases. - “What’s a sparse fieldset?” —
?fields=id,nameto return only listed fields. Saves bandwidth for clients who don’t need everything. The poor-man’s GraphQL.