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
− "purists" hate it (URL should name a resource, not a version)
API-Version: 2
− invisible; harder to test/curl by hand
application/
vnd.api.v2+json
− 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
DB walks + throws away
1,000,000 rows first
→ slower the deeper we go
→ shifts if rows change
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=shippedfilters wherestatus = "shipped". One param per field. - Sorting with a sign —
sort=-createdAtmeans descending bycreatedAt; drop the minus for ascending. Some APIs usesort=createdAt&order=descinstead — 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.