Versioning Strategies
Phase 1 left an uncomfortable truth: you can add to an API forever, but the day you genuinely must remove or redefine something, there's no in-place way to do it without breaking people. This phase is that day - the techniques that let an API change shape over years while clients built against its older shapes keep working.
The order here is also the order of preference: stretch as far as you can with additive changes, and only reach for a new version when you've truly run out of additive room. A version bump is the most expensive tool in the box - two code paths for you, a migration for clients. Used well, it's a clean break. Used reflexively, it's a treadmill.
First, exhaust the additive option
Before any versioning, ask: can I get what I want by adding instead of changing? Surprisingly often, yes - and it sidesteps the entire cost of a version.
- Want to rename
user_nametousername? Don't. Addusernamealongside it, populate both, point new clients at the new one, and let the old field live on (deprecate it later - nobody breaks today). - Want to change a field's type? Add a new field with the new type (
amount_decimalnext toamount) rather than mutating the existing one. - Want to change behavior? Gate it behind a new optional request parameter that defaults to the old behavior. Old clients omit it and get exactly what they got yesterday.
$ # Old clients omit the new param → old behavior, unchanged:
$ curl "https://api.example.com/orders?include=items"
$ # New clients opt in → new behavior:
$ curl "https://api.example.com/orders?include=items&group_by=warehouse"
Genuinely new behavior, no version, no migration, no parallel maintenance - the new capability is opt-in, and the default is "behave like before."
💡 Key point. Most "we need v2" instincts dissolve once you ask "what's the additive version of this?" Versioning is for when the shape itself must change in a way no addition can express - a wholesale restructuring, a removal you can no longer defer, a meaning shift you can't dual-write around.
When you must break: versioning
Sometimes additive genuinely can't get you there: the resource needs a fundamentally different shape, an old field has become actively harmful, or the model underneath changed so much that bolting on fields would make the API incoherent. That's when you version - via URL versioning or header versioning, and they make a real trade-off.
URL versioning Header versioning
/v2/orders Accept: application/vnd.example.v2+json
─────────────── ───────────────────────────── ─────────────────────────────────────
Visibility Obvious - it's in the URL Hidden - lives in a header
Try in a browser Yes, just paste the URL No, you must set a header
Routing/caching Easy: route & cache by path Harder: must vary by header
Granularity Whole API (or whole path) jumps Can version finely, per request
"RESTful purist" Disliked (URL = the resource, Preferred (one resource, many
view not its version) representations)
Who uses it Stripe-style date headers aside, very common; widely understood
Neither is "correct." URL versioning optimizes for obviousness and operational simplicity; header versioning optimizes for purity and fine-grained control. Most teams land on URL versioning - but pick for your audience, not the argument.
URL versioning - /v2/
The version is a path segment: /v1/orders, /v2/orders. A new major version is a new set of paths.
$ curl https://api.example.com/v1/orders/1138
{ "id": 1138, "price": 42 } # v1: price in dollars (the old, frozen contract)
$ curl https://api.example.com/v2/orders/1138
{ "id": 1138, "price_cents": 4200 } # v2: the redesigned shape
/v1/ keeps serving the exact old contract forever; /v2/ is free to be a clean, redesigned shape.
The version is impossible to miss - it's in every request, log line, bug report. The cost: "the
orders resource" now lives at two URLs (REST purists dislike this), and a client moving to v2 has to
change every URL it calls.
Header versioning - Accept: (or a custom header)
The URL stays /orders forever; the client states its version in a request header - commonly a
versioned media type in Accept, or a custom header.
$ curl https://api.example.com/orders/1138 \
-H "Accept: application/vnd.example.v1+json"
{ "id": 1138, "price": 42 }
$ curl https://api.example.com/orders/1138 \
-H "Accept: application/vnd.example.v2+json"
{ "id": 1138, "price_cents": 4200 }
Same URL, two representations, selected by header - one canonical address, and a client can move to v2 by changing a header rather than rewriting every path. The costs: you can't reproduce a v2 response by pasting a URL into a browser, your caches and routers must be configured to vary by that header (a classic source of "why am I getting the wrong version" bugs), and the version is easy to forget because it's not in the URL you read every day.
⚠️ Gotcha - don't version per tiny change. Whichever scheme you pick, a version should mark a deliberate, breaking redesign - not every little change. Cut a new version per field tweak and clients face an endless migration treadmill while you maintain a graveyard of code paths. Keep making additive changes within a version; reserve the bump for breaks you couldn't avoid.
Run the old and new in parallel
A version bump is only humane if the old version keeps working while clients migrate - the whole point of v2 is that v1 doesn't disappear the moment v2 ships.
time ──────────────────────────────────────────────────────►
v1 ████████████████████████░░░░░░░░░░░ (live, then deprecated, then off)
v2 ███████████████████████████████████ (live)
▲ ▲ ▲
v2 ships deprecation v1 sunset
announced (turned off)
└─── migration window ────────────┘
For a real stretch of time, both versions serve traffic. Clients migrate on their schedule inside the window, not yours; v1 only goes dark after a clearly communicated sunset. Skip the overlap - flip v1 off the day v2 lands - and "versioned" just becomes a breaking change with extra steps.
That overlap is the real cost of versioning: two code paths, two sets of tests, two things that can break, for as long as the window lasts. It's exactly why you exhaust additive changes first.
Deprecate on a clear timeline, with real communication
"Deprecated" doesn't mean "deleted." It means "still works, but is going away on a date we're
telling you now." A deprecation clients learn about via a 404 is not a deprecation - it's an
outage you scheduled and didn't mention.
📝 Terminology. Deprecate = officially mark as going-away, while keeping it working. Sunset = the date it actually stops working. The gap between them is the migration window.
A respectful deprecation does three things:
- Announce it where clients will see it - changelog, email to registered developers, dashboard banner. Not buried in a doc nobody re-reads.
- Signal it in the API itself, so clients who never read your email find out in their logs. The
HTTP
DeprecationandSunsetresponse headers exist for exactly this. - Give a real, generous timeline - proportional to how widely the thing is used. Days for an obscure beta endpoint; many months for a core resource thousands depend on.
$ curl -i https://api.example.com/v1/orders/1138
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 31 Oct 2026 23:59:59 GMT
Link: <https://api.example.com/docs/v2-migration>; rel="deprecation"
{ "id": 1138, "price": 42 }
v1 still returns a perfectly good 200 - nobody's broken today. But every response now carries
machine-readable proof that this version is on the clock: Sunset gives the exact cutoff date, Link
points at the migration guide. (Deprecation and Sunset are defined in IETF RFC 9745 and RFC 8594.)
⚠️ Gotcha - the silent majority. Some clients never read your changelog, email, or headers. Before you flip the switch on sunset day, look at your traffic - who's still calling v1? Reach out to the heavy holdouts directly if you can. Turning off a version that's still serving real traffic, on schedule but without a final check, is how a planned deprecation becomes someone's incident.
Putting the strategy together
- Can I do it additively? (New optional field, new endpoint, new opt-in parameter.) → Do that. No version, no migration. This is the answer most of the time.
- Must the shape itself break? → Cut a new version (URL
/v2/for obviousness, header for purity), and run it in parallel with the old one. - Ready to retire the old version? → Deprecate first - announce it, signal it in response headers, give a generous timeline, check who's still on it - then sunset.
Every step keeps the Phase 1 promise: clients depend on you and can't fix breakage themselves, so you change in ways they can absorb on their own schedule. Phase 3 turns the lens forward - designing the API so you land in step 1 far more often than step 2.
Recap
- Exhaust additive changes first - new fields, new endpoints, opt-in parameters - before reaching for a version. It's the cheapest evolution and avoids a migration entirely.
- When the shape must break, version: URL
/v2/trades RESTful purity for obviousness and easy routing/caching; header versioning trades convenience for a clean single-resource URL and finer control. - Run old and new in parallel through a real migration window - versioning without overlap is just a breaking change in disguise.
- Deprecate before you sunset: announce it, signal it with
Deprecation/Sunsetresponse headers, and give a timeline proportional to usage. - Before flipping a version off, check who's still calling it - the silent majority never reads your announcements.
Watch it animated: API versioning
← Guide overview · Phase 3: Designing for Longevity →
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. Before cutting a new version, you should first...
2. A version bump is only humane if...
3. "Deprecated" means...