Versioning & Evolution
How to version (URI vs. header vs. media type), the difference between breaking and non-breaking changes, and the tolerant-reader habit that lets APIs evolve without breaking clients.
On this page
Here's the hardest truth about APIs: you will need to change them, and you can't force consumers to change with you. A mobile app from last year, a partner's nightly job, a script nobody remembers - all depend on your current shape. Evolving an API without breaking them is arguably the senior API skill. It rests on two ideas: knowing which changes are breaking, and preferring evolution (compatible changes) over versioning (a hard fork).
Breaking vs. non-breaking changes
The foundational distinction. A non-breaking (backward-compatible) change lets existing clients keep working untouched; a breaking change forces them to update or they fail.
Non-breaking (safe to ship anytime):
- Adding a new optional field to a response.
- Adding a new endpoint or a new optional request parameter (with a sensible default).
- Adding a new enum value... usually (see the tolerant-reader caveat below).
Breaking (requires a version or a migration):
- Removing or renaming a field.
- Changing a field's type (
string→number) or its meaning. - Making an optional request field required, or tightening validation.
- Changing status codes or error semantics clients rely on.
The senior instinct: strive to make changes additively. Need to replace name with firstName/lastName?
Add the new fields, keep name populated for a deprecation period, and remove it only after consumers migrate.
Most "we need a new version" situations are avoidable with additive evolution.
The tolerant reader
Compatibility is a two-way contract. The Tolerant Reader pattern (Postel's Law - "be conservative in what you send, liberal in what you accept") says clients should ignore fields they don't recognize and not break when new ones appear:
// A tolerant reader: bind only the fields you need, ignore the rest
@JsonIgnoreProperties(ignoreUnknown = true) // don't blow up on unknown fields
record AccountView(Long id, BigDecimal balance) {} // ignores any other fields the server addsIf every client is a tolerant reader, the server can add fields freely - a huge amount of evolution freedom. Conversely, a brittle client that fails on any unexpected field turns your additive change into a breakage. Design your own clients this way, and document the expectation for consumers: unknown fields will be added; ignore them.
When you must version: the three styles
Sometimes a breaking change is unavoidable. Then you version. There are three common placements, each with trade-offs:
- URI versioning -
/v1/accounts,/v2/accounts. Dead simple, obvious in logs and browsers, easy to route. Purists dislike that the "same" resource has two URLs, but it's the most common and most pragmatic choice. - Header versioning - a custom header like
Api-Version: 2. Keeps URLs clean, but the version is invisible in a browser and easy to forget. - Media-type versioning -
Accept: application/vnd.ledger.v2+json. The most "correct" per HTTP purists (content negotiation), but awkward to use and test.
There's no universal winner; URI versioning wins on pragmatism for most teams and is easiest for consumers to understand. Whatever you choose, be consistent, and version at a coarse grain (the whole API or a major surface), not per-endpoint chaos.
Every version is a maintenance burden - minimize them
A new version isn't free: you now run and support v1 AND v2, often with shared logic and divergent behavior, until every consumer migrates - which can take years for public APIs. So the goal isn't 'version well,' it's 'version rarely.' Exhaust additive evolution and deprecation first. When you must cut a new version, publish a deprecation timeline for the old one, communicate it loudly, and actually sunset it - a graveyard of un-retired versions is its own kind of technical debt.
Your API is a public road thousands of drivers already use, with no way to make them all switch routes at once. A breaking change is like ripping up the road overnight - everyone crashes the next morning. Evolution is adding a new lane while keeping the old ones open (additive change): new traffic uses it, existing drivers are unaffected. The tolerant reader is drivers who calmly ignore a new sign they don't recognize rather than slamming the brakes. And when you truly must reroute, you build the new road (v2), keep the old one open with 'closing in 12 months' signs (deprecation), and only then remove it - never a surprise overnight teardown.
Your API returns { "name": "Ada Lovelace" } and you need to split it into firstName and lastName. You also want
to add a new nationality field, and eventually stop returning the combined name. Some consumers are third-party
apps you can't update. Lay out a non-breaking migration path, and say which single step (if any) is genuinely
breaking and how you'd handle it.
What is the best default strategy for evolving an API without breaking consumers?
Key takeaways
- An API must evolve, but you can't force consumers to upgrade - so evolving without breaking them is a core senior skill.
- Know breaking vs non-breaking: adding optional fields/endpoints is safe; removing/renaming fields, changing types, or tightening validation is breaking.
- Strive to change additively - add new fields and keep old ones through a deprecation period - which avoids most version bumps.
- The Tolerant Reader pattern (ignore unknown fields) lets servers add fields freely; design clients this way and document the expectation.
- When you must version, URI versioning (/v1, /v2) is the pragmatic default; every version is a maintenance burden, so version rarely and sunset old ones on a communicated timeline.