API Versioning, Pagination & Filtering

intermediate versioning pagination cursor filtering api-design

Once real clients depend on our API, we can’t just change it whenever we feel like it. And once our tables have millions of rows, we can’t return everything at once. Versioning and pagination are the two tools that keep a growing API from falling over. In simple language — versioning lets us change without breaking people, pagination lets us hand out big data in bite-sized pages.

Why version at all

An API is a contract. The moment someone builds against /users returning a name field, renaming that field to fullName breaks their app. We can’t ship breaking changes silently.

Versioning gives us an escape hatch: keep v1 alive for existing clients while v2 carries our shiny new shape. Old clients keep working, new clients opt in.

One nuance worth saying out loud: additive changes don’t need a new version. Adding a new optional field or a new endpoint doesn’t break anyone. We only bump the version for breaking changes — removing a field, renaming one, changing a type.

Three ways to version

URI path
GET /v1/users
+ dead obvious, easy to test in a browser
− "purists" hate it (URL should name a resource, not a version)
Custom header
GET /users
API-Version: 2
+ clean URLs
− invisible; harder to test/curl by hand
Media type
Accept:
application/
vnd.api.v2+json
+ most "RESTful", per-resource
− fiddly, few people do it well

Honest take for interviews: URI versioning (/v1/) is the most common because it’s the least surprising. Everyone can see it, test it, and cache it. Header and media-type versioning are “cleaner” in theory, but the invisibility makes them harder to work with day to day. Pick one and be consistent.

Pagination: offset vs cursor

If /users has 2 million rows, returning all of them is a disaster. We hand out pages instead. There are two schools.

Offset / limit — the simple one

GET /users?limit=20&offset=40

“Skip 40, give me the next 20.” Dead simple, and it lets us jump to any page (offset=40 is page 3). Great for a numbered pager on a small dataset.

The catch is two-fold. First, it gets slow at scale — the database still has to walk and count past all 40 (or 40,000) skipped rows before it can return the ones we want. Second, it’s unstable: if someone inserts a row while we page, everything shifts and we see a duplicate or skip an item.

Cursor / keyset — the scalable one

GET /users?limit=20&cursor=eyJpZCI6MTIzfQ

Instead of “skip N”, a cursor says “give me the 20 items after this specific item”. The cursor is usually an encoded pointer to the last row we saw (like its id or a created_at + id pair).

# response
{
  "data": [ ... 20 users ... ],
  "nextCursor": "eyJpZCI6MTQzfQ"
}

The query behind it becomes WHERE id > 123 ORDER BY id LIMIT 20, which uses the index directly — no counting past skipped rows. Fast and stable no matter how deep we page. The trade-off: we lose random page jumps. We can only go next/previous, not “jump to page 500”.

Why cursor scales better

Offset — page 50,000
LIMIT 20 OFFSET 1000000
DB walks + throws away
1,000,000 rows first


→ slower the deeper we go
→ shifts if rows change
Cursor — page 50,000
WHERE id > 1000000
ORDER BY id LIMIT 20
index seek, jumps
straight to the spot


→ same speed at any depth
→ stable under inserts

Rule of thumb: offset for small, admin-y lists where page numbers matter; cursor for big feeds, infinite scroll, and public APIs. This is a favorite interview question, so know the “why”.

Filtering & sorting

Neither of these needs new endpoints — they’re just query parameters on the collection.

GET /orders?status=shipped&minTotal=100&sort=-createdAt&limit=20

A few conventions that keep this sane:

  • Filter by field name?status=shipped filters where status = "shipped". One param per field.
  • Sorting with a signsort=-createdAt means descending by createdAt; drop the minus for ascending. Some APIs use sort=createdAt&order=desc instead — either is fine, just be consistent.
  • Combine freely — filters, sort, and pagination all live together on the same collection URL.
  • Whitelist everything — only allow filtering/sorting on fields we’ve explicitly indexed and approved. Blindly turning query params into SQL is both a performance and a security hole.

Practical takeaway

Version only on breaking changes, and /v1/ in the path is the pragmatic default. For paging, reach for offset/limit when users need page numbers on small data, and cursor/keyset for big or public datasets because it uses the index and stays fast and stable no matter how deep we scroll. Filtering and sorting are just query params on the collection — always whitelist the fields we allow.