Your API will change. New fields appear, old ones get renamed, a response shape that made sense at launch stops making sense at scale. The question is not whether you will change it, but whether you can do so without breaking every client that depends on you. That is what API versioning is for, and getting it right early saves enormous pain later.
Why you need versioning at all
Once someone integrates with your API, their code makes assumptions about your responses. Rename a field from name to full_name and their app breaks the moment you deploy. Versioning lets you introduce breaking changes behind a new version while old clients keep hitting the old one, on their own timeline instead of yours.
The common strategies
There are three widely used approaches, and each has a fair claim:
URL path: GET /v1/users
Query param: GET /users?version=1
Header: GET /users
Accept: application/vnd.myapi.v1+json
URL path versioning is the most popular for a reason: it is obvious, easy to test in a browser, and trivial to route — it also keeps each version a clean resource URL you can hit with a plain GET. Header versioning is arguably “purer” — the URL points at a resource, and the version is negotiated separately — but it is invisible and harder to debug. Query-param versioning is simple but muddies the line between a version and a filter. For most teams, the path wins on practicality.
API versioning strategies compared
Here are the four approaches you’ll meet in the wild, side by side — including the date-based scheme the big platforms use:
| Strategy | Example | Visibility & debuggability | Caching impact | Best for |
|---|---|---|---|---|
| URL path | GET /v1/users |
High — visible, browser-testable | Easy — distinct URLs cache cleanly | Most REST APIs; public APIs |
| Query param | GET /users?version=1 |
Medium — visible but blurs with filters | Trickier — version sits in the cache key | Quick add-ons; internal APIs |
| Header | Accept: application/vnd.api.v1+json |
Low — invisible; hard to debug by eye | Needs a Vary header to be safe |
“Purist” REST; content negotiation |
| Date-based | Stripe-Version: 2023-10-16 |
Medium — a header, but self-documenting dates | Per-request; handle deliberately | Fast-moving APIs with many small changes |
Date-based versioning: how Stripe and GitHub actually do it
Most tutorials stop at path-versus-header, but the largest APIs often reach for a fourth option: date-based versioning. Instead of v1/v2, each version is a calendar date, which fits APIs that ship many small changes rather than occasional big rewrites.
Stripe is the canonical example. Your account has a default version pinned to the date you started, and every request can override it with a Stripe-Version: 2023-10-16 header. Upgrading is deliberate: you test against a newer dated version, then move your account default when ready — so a change Stripe shipped years after you integrated never silently breaks you. GitHub similarly uses dated versions negotiated through a header, and Twitter/X takes the simpler path route with /2/ in the URL. The pattern to copy from Stripe: an account-level default plus a per-request override gives integrators both stability and a smooth, opt-in upgrade path.
What actually counts as a breaking change
This is the nuance people miss. If you only ever add, you can often avoid cutting a new version at all — so it pays to know exactly which side of the line a change falls on:
- Safe (non-breaking): adding a new optional field to a response; adding a whole new endpoint; adding a new optional request parameter; adding a new value to an enum if clients are documented to tolerate unknown values.
- Breaking: removing or renaming a field; changing a field’s type (
string→number); tightening validation so previously-accepted requests now fail; changing the meaning of an existing value or the semantics of a status code.
The through-line: additions are safe when clients read tolerantly; anything that removes or changes an existing promise is breaking. Design your clients to tolerate additions and you buy yourself a lot of freedom.
A quick note on semantic versioning
You’ll hear “just use semver” — major.minor.patch, like 2.4.1. It maps cleanly to the ideas above: a major bump means a breaking change, minor means backward-compatible additions, and patch means fixes. The catch for web APIs is that only the major number usually belongs in the URL (/v2/), because minor and patch changes are — by definition — non-breaking and should ship silently on the same version. That’s why /v1.2/ is almost always a smell: if a change is big enough to advertise, it’s a new major; if it isn’t, it needs no new number at all.
Have a deprecation plan
Versioning without a sunset plan just multiplies the number of APIs you maintain forever. When you ship v2, announce a timeline for retiring v1, signal it in responses (the standard Deprecation and Sunset HTTP headers are ideal), and give integrators real time to migrate. Communicate loudly and repeatedly — nobody reads the changelog until their app breaks.
Designing for addition: the art of not needing v2
The cheapest version bump is the one you never cut, and a few design habits push that day years out. Return objects, not bare values — {"total": 42} can grow a currency field later; a naked 42 cannot grow anything. Same for lists: wrap them ({"items": [...]}) so pagination metadata has somewhere to live when you inevitably need it. Use strings for enums rather than numbers, so new states don’t collide with clients’ switch statements. And document explicitly that clients must ignore unknown fields — then hold them to it by occasionally adding harmless ones.
On the client side of this contract, the discipline is called tolerant reading: parse what you need, ignore what you don’t recognize, and never fail because something extra appeared. When both sides play their role — servers only add, clients tolerate additions — an API can evolve remarkably far on a single version number.
Running two versions without going mad
The operational half of versioning is where teams actually suffer, because every live version multiplies maintenance. The sane pattern is to avoid forking your whole codebase per version. Instead, keep one internal model and translate at the edges: requests from old clients pass through a thin adapter that maps v1 shapes onto the current internals, and responses map back. The core business logic stays singular; only the translation layer knows v1 exists.
// One handler, thin per-version adapters at the edge
app.get('/v1/users/:id', async (req, res) => {
const user = await getUser(req.params.id); // current internal model
res.json(toV1(user)); // adapt: full_name -> name
});
app.get('/v2/users/:id', async (req, res) => {
const user = await getUser(req.params.id);
res.json(user); // v2 = current shape
});
// The only thing that "knows" about v1:
const toV1 = (u) => ({ id: u.id, name: u.full_name, email: u.email });
Notice there’s exactly one getUser and one place — toV1 — that remembers the old shape. When v1 finally dies, you delete one function and one route, not a parallel codebase.
Two supporting practices make this bearable. Log the version on every request — when you’re deciding whether v1 can finally die, “37 requests last month, all from one integration” is the data that settles it. And put version usage in your dashboards from day one; the question “who still uses v1?” should take ten seconds to answer, not an archaeology project.
Communicating a deprecation people actually hear
A deprecation plan is only as good as its delivery, and integrators are busy people who ignore changelogs. The escalation ladder that works: announce in the changelog and docs; add Deprecation and Sunset headers so well-built clients can detect it programmatically; email registered developers with a concrete date (or push a programmatic heads-up via webhooks if you have them); then — the step that actually gets attention — schedule brief brownouts near the end, where v1 intentionally returns error status codes for a few minutes. A ten-minute brownout two weeks before sunset surfaces every integration that slept through the emails, while the stakes are still low. It feels rude; it is far kinder than a permanent shutoff surprising them.
Give real timelines. Six months is a courteous minimum for a public API; a year is generous. Internal APIs between your own teams can move faster, but even there, a date beats “soon.”
Frequently asked questions
Should I version from day one? Put /v1/ in the path from the first release, yes — it costs nothing and makes the eventual v2 a routing change instead of a URL-scheme migration. But don’t build elaborate multi-version machinery before you have a second version; the prefix is the preparation.
What is a breaking change in an API? Any change that can break an existing client: removing or renaming a field, changing a field’s type, tightening validation so previously-valid requests fail, or changing the meaning of a value or status code. Purely additive changes — new optional fields or endpoints — are non-breaking as long as clients ignore what they don’t recognize.
What is the Sunset header? Sunset is a standard HTTP response header that tells clients the date a resource (or API version) will stop working, so well-built integrations can detect the deadline programmatically. It’s usually paired with a Deprecation header that marks the version as deprecated now.
What about GraphQL — does it need versioning? GraphQL‘s design leans hard on the “only add” philosophy: schemas evolve by adding fields and deprecating old ones (@deprecated), and clients request exactly the fields they use. Most GraphQL APIs never version at all. The lesson transfers back to REST: explicit field selection and additive evolution reduce versioning pressure everywhere.
Is /v1.2/ ever a good idea? Minor versions in the URL almost never earn their complexity. Non-breaking changes ship silently on the same version; breaking changes get a whole new major. If you find yourself wanting v1.2, you’re usually either shipping a non-breaking change (no bump needed) or kidding yourself about it being non-breaking.
Keep it boring
The best API versioning strategy is the one your team applies consistently. Pick path-based /v1/ versioning, add fields without bumping when you can, cut a new version only for genuinely breaking changes, and always publish a deprecation timeline. Boring, predictable versioning is exactly what your integrators are quietly hoping you will give them.

