Backend / REST APIs / 06_versioning_pagination.md

REST Versioning and Pagination

Updated 6 interview angles 5 min read source
On this page10
  1. Versioning — four common strategies
  2. When to bump the version
  3. Deprecation flow
  4. Pagination — three styles
  5. Pagination response shape
  6. Page size limits
  7. Filtering, sorting, fields selection
  8. Common pitfalls
  9. Common interview confusions
  10. Interview angle

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

text
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

http
GET /api/users?page=3&page_size=50
json
{
  "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

http
GET /api/users?cursor=eyJpZCI6MTAwfQ&limit=50
json
{
  "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:

sql
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 id or created_at + id for tie-breaking).

For feeds, infinite scroll, large datasets — cursor wins.

Time-window

For time-series and append-only data:

http
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?

json
{
  "data": [...],
  "meta": {"next_cursor": "...", "has_more": true}
}

vs envelope with links (HATEOAS-ish):

json
{
  "data": [...],
  "links": {
    "self": "/api/users?cursor=...",
    "next": "/api/users?cursor=...",
    "prev": null
  }
}

vs Link header (RFC 5988):

http
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

http
GET /api/users?page_size=1000000      # DoS via memory exhaustion

Always cap. Common default 25–100, max 1000. Reject requests above the cap:

http
HTTP/1.1 400 Bad Request
{"error": "page_size must be ≤ 1000"}

Or silently clamp:

python
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”:

http
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:

http
GET /api/users?filter[status]=active&filter[role]=admin

Or RSQL/FIQL:

http
GET /api/users?filter=status==active;role==admin

Pick a convention; document it. The frontend team thanks you.

Common pitfalls

  • Sort field not indexedsort=oldest_unindexed_column does 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, another offset, 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 id as 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 6

  • “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 50 slow?” — Postgres still scans 100050 rows to discard the first 100000. Cursor pagination uses WHERE id > last_seen_id against an indexed column.
  • “How do you handle deprecation?”Sunset and Deprecation HTTP 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,name to return only listed fields. Saves bandwidth for clients who don’t need everything. The poor-man’s GraphQL.