14 min read

Chapter 7: Versioning, Pagination & Filtering

"An API is a promise. Versioning is how you keep that promise
while still being allowed to change your mind."


1. Physical Reality First

The day you ship your API, three things are true:

  1. Some client will depend on the exact shape of your response
  2. You will need to change something within 6 months
  3. You cannot update every client simultaneously

This is the fundamental tension in API evolution. A mobile app deployed to 10 million devices cannot be force-updated overnight. A third-party integration built by another company cannot be broken on your release schedule. A webhook consumer expecting created_at in Unix timestamp format will break the moment you switch to ISO 8601.

Versioning is the mechanism that lets you break things — intentionally, controllably, on a timeline — without breaking existing clients.

Pagination is a parallel problem: your DB table has 10 million rows. GET /orders cannot return all of them. You need a mechanism to return them in chunks, consistently, without skipping or duplicating records as new data is written.

Both are decisions you make on day one. Both are nearly impossible to change later without breaking clients. Get them right upfront.


2. API Versioning

2.1 The Three Versioning Strategies

Strategy 1: URL Path Versioning

code
GET /v1/users/42
GET /v2/users/42
  • Version is in the URL — explicit, visible, unambiguous
  • Easy to test (just open in browser, paste in Postman)
  • Easy to route (reverse proxy can route /v1/* to old servers, /v2/* to new)
  • Violates the REST "resource has one URL" principle (same user, two URLs)
  • Used by: Stripe (/v1/), Twilio, Twitter, most large APIs

Strategy 2: Header Versioning

code
GET /users/42
API-Version: 2024-01-15

or

code
GET /users/42
Accept: application/vnd.example.v2+json
  • URL stays clean — the resource has one address
  • Harder to test (must set headers in every tool)
  • Not cacheable by default (CDNs cache by URL, not headers — you need Vary: API-Version)
  • Used by: GitHub (X-GitHub-Api-Version), Salesforce

Strategy 3: Query Parameter Versioning

code
GET /users/42?version=2
GET /users/42?api-version=2024-01-15
  • Easy to test, easy to route
  • Gets mixed into logs with other query params
  • Can accidentally be cached without Vary
  • Used by: Azure REST APIs, some AWS APIs

2.2 Which to Use

Use URL path versioning. It is the most pragmatic choice:

  • Immediately obvious which version a client is using
  • Trivial to route in Nginx/API Gateway: location /v1/ { proxy_pass http://v1-backend; }
  • Easy to enforce: strip /v1/ prefix in your router and pass the rest
  • Works perfectly with all caching layers — no Vary header needed
  • Battle-tested at Stripe, Twilio, Twitter

The REST purity argument against it (one resource, one URL) is real but not worth the operational pain of header versioning for most teams.

Version format: Use integer major versions (v1, v2, v3) not dates or semver. Dates (2024-01-15) are for fine-grained changelog versions (Stripe's approach). Integers are simpler and sufficient for major API versions.

2.3 What Counts as a Breaking Change

A breaking change requires a new version. A non-breaking change can be deployed to the current version.

Breaking changes (requires v2):

code
Removing a field from the response
Renaming a field (created_at → createdAt)
Changing a field's type (integer → string, object → array)
Changing a URL structure (/users/orders → /orders)
Removing an endpoint
Changing error codes clients might be checking
Making a previously optional field required
Changing authentication scheme

Non-breaking changes (safe to deploy to v1):

code
Adding new optional fields to response
Adding new endpoints
Adding new optional request parameters
Adding new enum values (risky — clients with exhaustive switch statements break)
Changing field ordering (clients must not depend on this)
Improving error messages (text only, not codes)
Adding new HTTP methods to an existing resource
Relaxing validation (accepting more inputs)

The "additive only" rule: On an existing version, only add. Never remove, rename, or change types.

2.4 Stripe's Date-Based Versioning (Advanced Pattern)

Stripe takes versioning further. Each API key is pinned to the version that was current when the key was created. Every breaking change creates a new date-version. The changelog is explicit:

code
2024-06-01: Changed `charge.amount` from cents integer to a Money object
2024-03-15: Removed deprecated `card` field from PaymentIntent, use `payment_method`
2023-11-01: `created` now returns ISO 8601 string instead of Unix timestamp

Stripe maintains backward compatibility for every version since 2011. Old integrations just work.

This is powerful but expensive to implement — you're maintaining multiple code paths or transformation layers per version. Only worth it for APIs with massive, diverse client populations. For most APIs, integer major versions with a 12-month deprecation window is sufficient.

2.5 Deprecation Strategy

When you release v2, don't immediately kill v1:

code
Phase 1 (Day 0):   Ship v2. Add Deprecation header to all v1 responses.
                   Sunset: Sat, 31 Dec 2025 23:59:59 GMT
                   Deprecation: true

Phase 2 (Month 3): Email all clients still using v1.
                   Track v1 usage by API key in metrics.

Phase 3 (Month 9): Block new API key registrations from using v1.
                   Contact high-volume v1 users directly.

Phase 4 (Month 12): Shut down v1. Return 410 Gone for all /v1/* routes.

The Deprecation and Sunset response headers (RFC 8594) are standard mechanisms to communicate this:

code
Deprecation: true
Sunset: Sat, 31 Dec 2025 23:59:59 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"

Clients that respect these headers can warn their own developers automatically.


3. Pagination

3.1 Why Pagination Exists

sql
SELECT * FROM orders;  -- 10 million rows

This query returns 10 million rows into memory, serializes them to JSON (probably 5–50 GB), and sends them over the network. The client waits indefinitely. The DB is locked. Everyone dies.

Pagination returns data in pages — controlled chunks. The client requests page 1, gets records 1–20, requests page 2, gets records 21–40, and so on.

There are three fundamentally different pagination mechanisms, each with different trade-offs.

3.2 Offset/Limit Pagination

code
GET /orders?offset=0&limit=20     → records 1–20
GET /orders?offset=20&limit=20    → records 21–40
GET /orders?offset=40&limit=20    → records 41–60

Under the hood:

sql
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 40;

How it works: OFFSET N tells the DB to skip N rows. LIMIT 20 returns the next 20.

Response:

json
{
  "data": [...],
  "pagination": {
    "total": 1847,
    "offset": 40,
    "limit": 20,
    "next": "/orders?offset=60&limit=20",
    "prev": "/orders?offset=20&limit=20"
  }
}

Advantages:

  • Simple to implement and understand
  • Jump to any page: offset=500 → page 26
  • Easy total count: run SELECT COUNT(*) FROM orders

Fatal flaw — data drift:

code
Page 1 request:  records 1–20 (at t=0)
Someone inserts a new order (at t=1)  ← new record at position 1
Page 2 request:  offset=20 (at t=2)
  → returns records 21–41 (what was previously 20–40)
  → record 20 (now position 21) is returned again
  → record 21 (now position 22) is also returned again
Result: client sees duplicate records

Conversely, if a record is deleted between pages, the client skips one record — a silent data gap.

Performance flaw:

sql
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 100000;

The DB must scan and discard 100,000 rows to return 20. At offset 1,000,000, this takes seconds. Offset pagination degrades to O(N) per page as offset grows.

Use offset/limit when: Total count is needed (admin dashboards, search result counts), dataset is small (<10,000 rows), random access to pages is required, data changes infrequently.

3.3 Cursor-Based (Keyset) Pagination

Instead of "skip N rows," cursor pagination says: "give me records after this specific record."

code
GET /orders?limit=20                              → first page
GET /orders?after=cursor_eyJpZCI6MTAwfQ&limit=20  → next page
GET /orders?after=cursor_eyJpZCI6MTIwfQ&limit=20  → next page

The cursor encodes the position of the last record seen. It's typically Base64-encoded JSON:

python
# Cursor encoding
import base64, json
cursor_data = {"id": 100, "created_at": "2024-01-15T10:30:00Z"}
cursor = base64.b64encode(json.dumps(cursor_data).encode()).decode()
# → "eyJpZCI6IDEwMCwgImNyZWF0ZWRfYXQiOiAiMjAyNC0wMS0xNVQxMDozMDowMFoifQ=="

Under the hood:

sql
-- Instead of OFFSET, use a WHERE clause on the cursor position
SELECT * FROM orders
WHERE created_at < '2024-01-15T10:30:00Z'
   OR (created_at = '2024-01-15T10:30:00Z' AND id < 100)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Response:

json
{
  "data": [...],
  "pagination": {
    "next_cursor": "cursor_eyJpZCI6MTIwfQ",
    "prev_cursor": "cursor_eyJpZCI6MTAxfQ",
    "has_next": true,
    "has_prev": true
  }
}

Advantages:

  • Stable: New inserts/deletes don't shift cursor position. No duplicate records, no skipped records.
  • Performant: The WHERE clause uses an index. O(log N) regardless of how deep you are.
  • Infinite scroll friendly: Perfect for feeds, timelines, activity streams.

Limitations:

  • No random access: can't jump to page 50
  • No total count (not easily — requires a full table scan)
  • Cursor is opaque to clients (they can't construct one)
  • Sort order must include a unique column (usually id) to break ties

Use cursor pagination when: Dealing with large datasets, real-time data (feeds, logs, events), infinite scroll UIs, performance matters at large offsets.

GitHub, Stripe, Twitter, Slack all use cursor-based pagination.

3.4 Page Number Pagination

code
GET /orders?page=1&per_page=20
GET /orders?page=2&per_page=20

This is syntactic sugar over offset/limit: offset = (page - 1) * per_page. All the same trade-offs apply. The response is more human-friendly:

json
{
  "data": [...],
  "pagination": {
    "page": 2,
    "per_page": 20,
    "total_pages": 93,
    "total": 1847
  }
}

Use when you're building admin interfaces where humans want to see "Page 2 of 93."

3.5 Which Pagination to Use — Decision Tree

code
Is total count required by the UI?
├── Yes → Offset/limit or page-based
│         (accept the performance cost, add a COUNT query)
└── No  → Is dataset > 100k rows OR data changes frequently?
          ├── Yes → Cursor-based (keyset)
          └── No  → Either works; offset/limit for simplicity

3.6 Pagination Defaults and Limits

Always have defaults. Always enforce maximums:

code
Default page size:  20
Maximum page size:  100 (never allow unlimited)

If a client sends ?limit=10000, return 400 Bad Request:

json
{
  "code": "invalid_parameter",
  "detail": "limit must be between 1 and 100. Got: 10000"
}

4. Filtering, Sorting, and Searching

4.1 Filtering

Filters narrow the result set. Always via query parameters:

code
GET /orders?status=pending
GET /orders?status=pending&user_id=42
GET /orders?created_after=2024-01-01&created_before=2024-02-01
GET /products?category=electronics&min_price=1000&max_price=50000
GET /users?is_active=true&role=admin

Range filters — consistent naming:

code
created_after  / created_before     ← date ranges (exclusive)
amount_gte     / amount_lte         ← numeric ranges (inclusive, "greater than equal")
min_price      / max_price          ← alternative naming (pick one, use it everywhere)

Multi-value filters:

code
GET /orders?status=pending&status=processing     ← multiple values for same param
GET /orders?status[]=pending&status[]=processing ← array syntax (PHP-style)
GET /orders?status=pending,processing            ← comma-separated

Pick one style and be consistent. Comma-separated is cleanest in URLs. Stripe uses it.

What you can filter on: Any indexed column is fair game. Never filter on non-indexed columns — it causes full table scans. If a client needs to filter by user.bio (a text field), that's a search problem, not a filter problem.

4.2 Sorting

code
GET /orders?sort=created_at&order=desc
GET /orders?sort=-created_at            ← minus prefix = descending (GitHub style)
GET /products?sort=price&order=asc
GET /users?sort=name&order=asc&sort2=created_at&order2=desc  ← multi-sort (complex)

Always have a default sort order and document it. Clients must not depend on implicit ordering — "the DB returns them in insertion order" is not a contract.

Safe sort fields: Only allow sorting on fields you have DB indexes on. A client requesting sort=bio on a text field triggers a filesort on the entire table.

Validate sort field values:

json
GET /orders?sort=nonexistent_field
→ 400 {
    "code": "invalid_sort_field",
    "detail": "Invalid sort field 'nonexistent_field'. Allowed: id, created_at, amount, status"
  }

4.3 Searching

Full-text search is a different beast from filtering:

code
GET /products?q=wireless+mechanical+keyboard
GET /users?search=trilochan

The q or search parameter triggers full-text search. Under the hood this is NOT a LIKE '%keyboard%' query (full table scan, slow). It uses:

  • PostgreSQL full-text search: tsvector + tsquery with GIN index
  • Elasticsearch/OpenSearch: Inverted index (Chapter 24 of the Distributed Systems book)
  • Algolia/Typesense: Hosted search services

Never use LIKE '%term%' for search in production. The leading wildcard kills index use — full table scan every time.

For simple cases (admin search on small tables), LIKE 'term%' (prefix match, no leading wildcard) uses indexes and is acceptable.

4.4 Combining Filter, Sort, Search, Pagination

Real requests combine all of these:

code
GET /products
  ?q=laptop
  &category=electronics
  &min_price=30000
  &max_price=150000
  &brand=apple,dell
  &sort=-created_at
  &page=2
  &per_page=20

Your backend must apply them in the right order:

code
1. Filter (WHERE clause: category, price range, brand)
2. Search (full-text match on q)
3. Sort (ORDER BY created_at DESC)
4. Paginate (LIMIT 20 OFFSET 20)

The total count (if needed) must be computed after filter+search but before pagination:

sql
-- Efficient: count filtered results
SELECT COUNT(*) FROM products
WHERE category = 'electronics'
  AND price BETWEEN 30000 AND 150000
  AND to_tsvector(name) @@ plainto_tsquery('laptop');

Instead of a pagination object in the body, GitHub puts pagination links in the Link header:

code
Link: <https://api.github.com/repos/octocat/hello/issues?page=3>; rel="next",
      <https://api.github.com/repos/octocat/hello/issues?page=50>; rel="last",
      <https://api.github.com/repos/octocat/hello/issues?page=1>; rel="first",
      <https://api.github.com/repos/octocat/hello/issues?page=1>; rel="prev"

This is pure REST — hypermedia links in the response (HATEOAS, Chapter 2). The body is pure data. Pagination metadata is in headers.

The rel values are standardized (RFC 5988): next, prev, first, last.

Trade-off: Headers are less visible (developers don't see them by default), but body stays clean. For public APIs consumed by sophisticated developers, this is elegant. For internal APIs or ones consumed via SDK, body pagination is easier.


6. How Real Systems Do It

Stripe — cursor-based:

code
GET /charges?limit=10&starting_after=ch_3OaB1234

starting_after is the ID of the last charge on the previous page. Returns the next 10 charges after it. Also supports ending_before for reverse pagination.

json
{
  "object": "list",
  "data": [...],
  "has_more": true,
  "url": "/v1/charges"
}

No total count. Just has_more. Simple, honest.

GitHub — header-based links, page numbers:

code
GET /repos/torvalds/linux/commits?page=2&per_page=30
Link: <...page=3>; rel="next", <...page=1>; rel="prev"

Page numbers (offset under the hood) but with Link headers. Works for repos because commit history is append-only (no drift problem).

Twitter/X — cursor-based for timelines:

code
GET /tweets/search/recent?max_results=100&next_token=abc123

next_token is an opaque cursor. Timeline data changes rapidly — cursor pagination is essential.

Elasticsearch — offset with search_after for deep pagination:

json
POST /orders/_search
{
  "size": 20,
  "sort": [{"created_at": "desc"}, {"id": "desc"}],
  "search_after": ["2024-01-15T10:30:00Z", 100]
}

search_after is keyset pagination at the search layer. Avoids the deep offset performance cliff.


7. Vocabulary Map

TermDefinition
Breaking changeAn API change that causes existing correct clients to malfunction. Requires a new version.
Non-breaking changeAn additive change (new fields, new endpoints) safe to deploy to the current version.
Offset paginationPagination via OFFSET N LIMIT M in SQL. Simple but drifts on concurrent writes and degrades at large offsets.
Cursor paginationPagination using an opaque token encoding the last-seen position. Stable and performant.
Keyset paginationCursor pagination using indexed column values in a WHERE clause instead of OFFSET.
CursorAn opaque, Base64-encoded value representing a position in a dataset. Clients pass it back verbatim.
Data driftOffset pagination bug: inserted/deleted records between pages cause duplicates or gaps.
DeprecationMarking an API version or endpoint as scheduled for removal. Communicated via Deprecation header.
SunsetThe date an API version or endpoint will stop working. Communicated via Sunset header (RFC 8594).
FilteringNarrowing results by field values via query params (?status=pending).
SortingOrdering results by a field (?sort=created_at&order=desc).
Full-text searchSearching across text fields using inverted indexes. Not LIKE '%term%'.
has_moreBoolean field indicating whether more pages exist. Used instead of total count in cursor pagination.
Link headerHTTP response header containing hypermedia pagination links (RFC 5988). Used by GitHub.
Vary headerResponse header telling caches to vary cached responses by the named request header. Required when versioning via headers.

8. Practice Problems

Problem 1 — Failure Analysis: Your API uses offset pagination. You have a feed endpoint GET /posts?offset=0&limit=20 that shows the 20 most recent posts. 50,000 users hit this endpoint simultaneously during a viral moment. The server dies. Identify two separate performance problems (one at the DB layer, one at the application layer) caused by offset pagination under this load. How does cursor pagination solve each?

Problem 2 — Design From Scratch: Design the complete pagination, filtering, and sorting system for GET /transactions on a fintech API. Transactions can number in the tens of millions per user. Requirements: (a) clients need to scroll through all transactions chronologically (mobile infinite scroll), (b) clients need to filter by date range, type (debit/credit), and amount range, (c) clients need to search by description text, (d) a dashboard needs total count and sum for filtered results. Specify query params, response structure, and the SQL/query strategy for each requirement.

Problem 3 — Synthesis: Your v1 API returns user objects with "name": "Trilochan B" as a single field. For v2, you want to split this into "first_name": "Trilochan" and "last_name": "B". You also want to change "created_at" from a Unix timestamp to ISO 8601. Design: (a) the v1 and v2 response schemas, (b) how your codebase handles both versions without duplicating all route logic, (c) the deprecation timeline and headers, (d) what GET /v1/users/42 returns after v1 is sunsetted.

Problem 4 — Go Deeper: Implement cursor encoding and decoding in pseudocode/Python for a cursor that encodes (created_at, id) for stable pagination over orders sorted by created_at DESC, id DESC. Show: (a) cursor generation from the last row of a page, (b) cursor decoding on the next request, (c) the SQL WHERE clause that uses the decoded cursor. Handle the edge case where two orders have the exact same created_at timestamp.

Problem 5 — Connect to Previous: From Chapter 4, you know GET responses should be cacheable. From this chapter, you know cursor-based pagination uses opaque tokens in query params (?after=cursor_abc). From Chapter 2, you know caching is a REST constraint. Explain why GET /orders?after=cursor_abc is naturally cache-friendly (unlike POST-based pagination). What response headers would you set to cache this response for 30 seconds? What happens to the cache when a new order is inserted — is that a problem, and why or why not?


What's Coming Next

Your API can evolve and serve large datasets. Now: how do you make it fast?

Chapter 8 covers the two HTTP-native performance mechanisms: caching and compression. ETags, Cache-Control directives, conditional requests (304 Not Modified), the Vary header, and Brotli/gzip compression. These techniques can eliminate 90% of your origin server load with zero application code changes — just headers your API sets correctly.