You will need to change your API, and someone’s code depends on the old one. Versioning is how you evolve without breaking them — and the best version is often no version at all.
Prerequisites: API Design Principles, Serialization Time to read: ~18 minutes
You ship v1. Six months later you need to rename a field, restructure a response, or change a behaviour.
🚨 But your API isn’t yours anymore. A mobile app from last year is calling it — and mobile apps never fully update, so old versions live forever. A partner integrated against it. Someone wrote a script. You cannot deploy their fix; you can only avoid breaking them.
The core distinction that governs everything:
Know this cold — it determines whether you need a new version at all.
Non-breaking (safe to ship without a version bump):
| Change | Why safe |
|---|---|
| Adding a new endpoint | Old clients don’t call it |
| Adding an optional request field | Old clients don’t send it; server defaults it |
| Adding a field to a response | 🚨 Only if clients ignore unknown fields (see below) |
| Adding a new optional enum value on a request | Old clients don’t send it |
| Relaxing a validation rule | Previously-valid requests still valid |
| Adding a new error code clients handle generically | Handled by the generic path |
Breaking (needs a version, or a migration):
| Change | Why breaking |
|---|---|
| Removing or renaming a field | Clients reference it |
| Changing a field’s type or meaning | Silent corruption |
| Adding a required request field | Old clients don’t send it → rejected |
| Removing an endpoint | Clients call it |
| Changing response structure | Clients parse the old shape |
| Making validation stricter | Previously-valid requests now rejected |
| Adding an enum value clients receive | 🚨 Clients may not handle the unknown value |
🚨 Two subtle ones worth calling out:
Adding a response field is only safe if clients ignore unknown fields. This is the single most important forward-compatibility property, and you should establish it as a rule from day one (and enforce it in your own client SDKs). → Principles
Adding an enum value clients receive is breaking, even though adding one they send isn’t. A
client with switch (status) { case A, B, C } and no default will crash or misbehave on a new value
D. This catches people out. Design consumers to handle unknown enum values gracefully from the
start.
And the sneakiest of all: changing behaviour without changing the shape. Same fields, same types, but the meaning changed — a field now measured in a different unit, a default that changed, a status that now means something slightly different. No schema check catches this, and it breaks clients silently. → Serialization
GET /v1/users/42
GET /v2/users/42
✅ Obvious, easy to route, easy to test, visible in logs. The pragmatic default and what most public APIs use. ❌ Not “pure REST” (the resource is arguably the same). Encourages whole-API version jumps rather than granular evolution.
GET /users/42
Accept: application/vnd.example.v2+json
or a custom header (X-API-Version: 2).
✅ Keeps URLs clean; the resource URL is stable. More “RESTful.” ❌ Less visible (can’t test in a browser, harder to see in logs), and easy for clients to forget.
GET /users/42?version=2
✅ Simple. ❌ Clutters URLs; easy to omit.
🚨 The approach the best APIs actually converge toward. Never make breaking changes; only add. Fields are added, never removed or repurposed. There’s no v2 because you never break v1.
✅ No version proliferation; clients never forced to migrate; simplest for consumers. ❌ Requires discipline; the API accretes fields over time; you can’t clean up mistakes.
🎙️ “My preference is additive-only evolution — never a breaking change, so no versioning is needed. When a breaking change is genuinely unavoidable, URL path versioning, because it’s the most visible and easiest to operate.”
🚨 Worth knowing as the gold standard, because it solves the hardest problem — cleaning up mistakes without forcing migrations.
Each account is pinned to the API version (a date) that was current when they integrated:
Stripe-Version: 2024-06-20
The clever part: Stripe keeps every version working, and internally maintains transformation layers that convert between versions. A request on an old version is transformed up to the current internal representation, processed, and the response transformed back down.
📐 The result: integrations from years ago keep working unchanged, while new integrations get the latest design — and Stripe can fix mistakes in new versions without touching old clients. They’ve run this for over a decade.
⚖️ The cost is real: you maintain transformation logic for every version delta, forever. It’s a significant engineering investment, justified for a company whose product is its API. Most systems don’t need it, but it’s the right reference point for “how do the best do it?”
When you must retire a version, do it gradually and communicated:
1. ANNOUNCE → tell consumers, with a timeline (months, not weeks)
2. DEPRECATE → mark it deprecated; add a `Deprecation` / `Sunset` header
3. MONITOR → track who's still using it (per-version metrics)
4. NUDGE → contact remaining users; brownouts (brief planned outages) for stubborn ones
5. SUNSET → turn it off, after the deadline
🚨 Two operational essentials:
Sunset and Deprecation HTTP headers (RFC-standardized) tell clients programmatically that an
endpoint is going away and when.Brownouts — deliberately returning errors for a deprecated endpoint for a few hours, then restoring it — are a real technique to force the attention of clients ignoring your emails. GitHub and others use them.
Design v1 to be evolvable. Use objects instead of bare arrays in responses (so you can add
metadata later), avoid over-specifying, and leave room. A response of [...] can never gain a
pagination field; {"data": [...]} can. This is a genuinely important early decision.
Version at the largest sensible granularity. Per-field versioning is chaos. Version the whole API (or a whole resource), not individual fields.
Support at least N-1. When you release v2, keep v1 running for a defined window. Never break clients the moment a new version ships.
Make the change non-breaking if you possibly can. 🚨 Most “breaking” changes can be avoided: add a new field instead of changing one, add a new endpoint instead of altering behaviour, accept both old and new formats. A breaking change is a last resort, not a first tool.
Communicate relentlessly. Changelogs, deprecation headers, email, dashboard warnings. Surprise breakage destroys trust in your API, which is hard to rebuild.
⚖️ The versioning rigor scales with how much control you have over consumers:
🎙️ “For internal services I’d rely on backward-compatible schema evolution and contract tests, since we control both ends. For the public API I’d use explicit URL versioning with a proper deprecation lifecycle, because we can’t coordinate with mobile clients or partners.”
| Strategy | Gain | Cost |
|---|---|---|
| Additive-only (no versions) | No migrations; simplest for clients | Requires discipline; can’t fix mistakes; API accretes |
| URL path versioning | Visible, simple to route and test | Encourages whole-API jumps; URLs change |
| Header versioning | Clean URLs; stable resource | Less visible; clients forget it |
| Date-based (Stripe) | Old integrations never break; can fix mistakes | Transformation layers to maintain forever |
| Long deprecation windows | Clients aren’t surprised | Maintain old versions longer |
1. Classify changes. Take a real API endpoint. For each of these, decide breaking or not, and why: add an optional query param; add a response field; rename a field; add an enum value returned in responses; make an optional field required; change a timestamp from epoch to ISO. Getting the enum and the required-field ones right is the test.
2. Break a client with an enum. Build a client with a switch over a status enum and no default.
Add a new status value on the server. Watch the client mishandle it. Then add a default case and watch
it degrade gracefully. This is the subtle breaking change, demonstrated.
3. Instrument version usage. Add a per-version request counter to a multi-version API. Generate traffic across versions. Now you can answer “who’s still on v1?” — the prerequisite for safe deprecation.
4. Design an evolvable response. Write a v1 response as a bare array [...]. Now try to add a
nextCursor to it — you can’t without breaking clients. Redesign as {"data": [...]} and add it
cleanly. That’s why the wrapper matters.