backend / rest apis / 06_versioning_pagination.md

REST Versioning and Pagination

6 interview angles 5 min read source

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

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

  • “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.