system-design

Versioning and Backward Compatibility

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


The problem

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:


What’s breaking and what isn’t

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


Versioning strategies

1. URL path versioning — the most common

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.

2. Header versioning

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.

3. Query parameter versioning

GET /users/42?version=2

✅ Simple. ❌ Clutters URLs; easy to omit.

4. No explicit versioning — additive-only evolution

🚨 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.”


Stripe’s model: date-based versioning

🚨 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?”


The deprecation lifecycle

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:

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.


Practical guidance

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.


Internal vs external APIs

⚖️ 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.”


⚖️ Trade-offs

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

In the real world


🚨 Interview traps


🎙️ Soundbites


🛠️ Try it

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.


Check yourself

1. Why can't you just fix an API the way you'd fix any other code? Because the API is a contract that other people's code depends on, and you can't deploy their fix. A mobile app from last year calls your API, and mobile apps never fully update — a meaningful fraction of users run months- or years-old versions permanently. A partner integrated against it and their code is frozen. Someone wrote a script. When you change the API, all of those break, and unlike an internal refactor (where you fix every caller), you have no way to update the callers. So you can only make changes that *don't* break existing clients, or manage breaking changes through versioning and long deprecation windows — never the "just ship the fix" approach that works for internal code.
2. Which is breaking: adding an enum value clients send, or an enum value clients receive? Adding an enum value clients *receive* is breaking; adding one they *send* is not. If clients can send a new value, old clients simply won't send it — no impact. But if the server starts *returning* a new enum value, a client with a `switch` statement covering only the known values and no default case may crash, throw, or silently fall into wrong behaviour when it encounters the unrecognized value. Neither the schema nor the serializer flags this, because the data still parses. The defence is to design consumers to handle unknown enum values gracefully from the start (log it, treat as a documented "unknown" state, route to a default), and to deploy consumers that handle the new value before the producer starts emitting it.
3. Why is "additive-only evolution" often better than explicit versioning? Because it never forces clients to migrate. If you only ever add — new endpoints, new optional fields, new optional parameters — and never remove, rename, repurpose, or tighten, then old clients keep working forever with no changes, and there's no v2 to build, document, route, or maintain. Clients adopt new capabilities when they're ready, without a deadline. The costs are discipline (you must resist breaking changes even when the old design was a mistake) and accretion (the API accumulates fields and endpoints over time, and you can't clean up errors). For most APIs that trade is worth it, and the best public APIs converge toward it — reserving actual versioning for the rare genuinely unavoidable breaking change.
4. What makes Stripe's date-based versioning notable? That it lets old integrations keep working *unchanged* forever while new integrations get the latest design, *and* it lets Stripe fix mistakes in new versions. Each account is pinned to the API version (a date) current when they integrated, and Stripe maintains internal transformation layers that convert requests up from any old version to the current internal representation and responses back down. So a decade-old integration behaves exactly as it did on day one, without the client changing anything, and Stripe isn't stuck with early design errors. The cost is maintaining transformation logic for every version delta indefinitely — a significant, ongoing engineering investment justified for a company whose product is fundamentally its API.
5. Why do you need per-version usage metrics before deprecating a version? Because deprecation without knowing who still uses a version means breaking someone blind. You need to answer "how many clients, and which ones, are still calling v1?" to decide whether it's safe to turn off, to target outreach at the specific integrators still on it, and to confirm usage has actually dropped to zero (or to a knowingly-accepted remainder) before sunset. Without these metrics you're guessing, and the guess will eventually be wrong — you'll disable a version that a critical partner or a slice of mobile users still depends on. Instrumenting per-version request counts from the start is what makes the whole deprecation lifecycle — announce, deprecate, monitor, nudge, sunset — actually executable rather than a hopeful email.

Further reading