API Versioning Without Breaking Customers
A public API is a contract with other people's code. How to evolve it without breaking your customers' production. For CTOs, API architects and backend teams.
What is this? · Reference Guide
A solid guide to an engineering question — with trade-offs, costs and the case in which we decide differently. Not an opinion piece, but a reference text. Go to overview
- Author
- Batunet Engineering
- Reading time
- 14 min
- Level
- In depth
- Status
- Approved
- Last reviewed
- 21 July 2026
- Updated
- 21 July 2026
On this page
On this page
- Why an API is a promise
- What a breaking change is — and what it isn't
- The principle: evolution before versioning
- Extending in a backward-compatible way
- When a breaking change is unavoidable
- Where the version lives: URL, header or media type
- Running two versions in parallel
- Deprecation is a process, not an event
- Test contracts before the customer does
- Communication: changelog, deadlines, migration path
- Common mistakes
- Checklist
- FAQ
- Further reading
As soon as another system calls your API, part of your code no longer belongs to you alone. Every response you return, every field name, every error code is a promise that someone else's code relies on. Changing an API therefore does not mean changing your own code — it means reaching into the production of people you will never meet.
That is why API versioning is one of the least forgiving disciplines in software engineering. Internally, you can fix a bug, deploy and move on. At a public interface, a careless change is a silent outage in someone else's systems — noticed not by you but by your customer's customer. This text is about how to evolve an API over years without breaking that promise: compatibly where possible; cleanly versioned where necessary; and with an exit that surprises no one.
Why an API is a promise
An API's contract is everything a caller can rely on: the structure of the response, the meaning of the fields, the semantics of errors, the behavior in edge cases. Much of this is written down nowhere in the documentation — it lives in the code of customers who expect a particular response shape and have built on it. The real contract is larger than what you promised; it covers everything customers were able to observe.
This leads to the basic stance: for a public API, backward compatibility is not a courtesy but the default. You only change what you can change without existing callers noticing — and treat every visible change as what it is: an intervention in someone else's systems.
What a breaking change is — and what it isn't
Before thinking about versioning, you have to recognize what actually breaks. The line does not run between "small" and "large" but between "an existing caller notices it" and "it doesn't notice."
As a rule, additive changes are non-breaking: a new, optional field in the response; a new optional parameter with a sensible default; a new endpoint; a new value in an extensible enumeration, if clients tolerate unknown values.
Breaking changes are those that violate an existing expectation: removing or renaming a field; changing a field's type or format; making a previously optional field required; tightening validation; changing a default value; shifting the meaning of an error code; changing an order or semantics someone could rely on. Tightening limits — a lower limit, a stricter format — also breaks, even though no field disappears.
The uncomfortable part: some customers rely on behavior you never promised. That is why the safest assumption is that every observable change is breaking for someone until proven otherwise.
The principle: evolution before versioning
A new version is expensive — for you, because you run two contracts, and for customers, because they have to migrate. That is why a new version is the last resort, not the first reflex. Most changes can be made compatibly if the API is designed for evolution from the start.
That means: extend additively instead of restructuring, optional fields instead of required ones, tolerant readers on both sides. An API designed this way grows for years without a single version bump — and that is exactly the goal. A version is an admission that you can no longer move forward compatibly; you should make it rarely and deliberately.
The recommendation: treat a new version as a last resort and solve as much as possible through compatible evolution. The price is that the compatible solution is rarely the most elegant — you carry legacy baggage, accumulate optional fields and live with names you would choose differently today. We decide differently when the model has become fundamentally wrong or a security requirement forces an incompatible change — then a new version is the more honest answer than a chain of contortions that obscure the contract.
Extending in a backward-compatible way
The tool of compatible evolution is the same as in data migration: grow additively, never change what already exists. New fields are added without old ones disappearing. New parameters are optional and have a default that preserves the previous behavior. Where a field is to be replaced, both exist side by side for a while before the old one eventually disappears — in a new version.
The second half is the tolerant reader: both sides ignore what they don't know. A client that ignores unknown fields and doesn't crash on new enumeration values allows the server to grow additively without breaking it. A server that doesn't immediately reject unknown input fields lets clients build ahead. Strict in what you guarantee; tolerant in what you accept.
Diagram: An additive field breaks no one — the old client ignores what it doesn't know.
The recommendation: extend additively and build in tolerance on both sides. The price is that fields accumulate, almost everything becomes optional and over time the API carries more than a fresh design would have contained — discipline is needed so this doesn't tip into arbitrariness. We decide differently for a purely internal API whose callers we roll out in the same step — there, tolerance only costs precision, and a direct, coordinated break is cleaner than permanent optionality.
When a breaking change is unavoidable
Sometimes it can't be done compatibly. The model has changed fundamentally, an error in the contract has to be corrected, or a consolidation cannot be expressed additively. Then — and only then — a new version is created. A version is not a marketing number but a boundary: behind it applies a different, clearly named contract, and the old version remains valid and untouched for as long as customers need it.
The decisive rule: a new version does not break the old one. It sits alongside it. Whoever rolls out "version 2" and changes "version 1" in the process has not versioned but merely renamed the break.
The recommendation: introduce a new major version only for genuine breaking changes, and bundle several of them instead of cutting a version for every small incompatibility. The price is that desired breaks have to wait until a version bump is worthwhile, and that every version you open is dragged along for years. We decide differently for an API before its first stable release that is explicitly marked as unstable — there you may break things, because nobody has yet received a promise they were entitled to build on.
Where the version lives: URL, header or media type
If there has to be a version, the question is where it becomes visible. There are three established places, and none comes without a price.
| Placement | Example | Advantage | Price | When |
|---|---|---|---|---|
| URL path | /v2/orders | visible, cacheable, easy to test | version sticks to the resource identity | public APIs, coarse versions |
| Header | Api-Version: 2 | URL stays stable across versions | invisible, easy to forget, caching pitfalls | controlled, internal clients |
| Media type | Accept: …vnd.batunet.v2+json | fine-grained, close to the HTTP model | complex, high tooling effort | hypermedia, fine-grained evolution |
For most public APIs, the version in the URL path is the right choice: it is visible, unambiguous in logs and caches, instantly testable in any tool and understandable to customers without explanation. The price is conceptual — the version becomes part of the resource URL even though the same resource is meant, and versions tend to multiply in the path.
The recommendation: version public APIs coarsely via the URL path. The price is the conceptual imprecision and the tendency toward version proliferation if you cut a new number too easily. We decide differently for fine-grained evolution or hypermedia APIs, where media type versioning stays truer to the HTTP model, and for purely internal clients, where a header is enough because you control server and callers together.
Running two versions in parallel
As soon as a second version exists, two contracts run at the same time — and the expensive trap is to build two systems for them. The right way is one implementation at the core and a thin translation at the edge: a version router receives the request, translates it into the internal model and shapes the response back into the respective promised contract. The core knows no versions; only the edge does.
Diagram: The core knows no versions; only the edge translates into the respective contract.
The recommendation: run multiple versions as translations on top of a shared implementation, not as separate stacks. The price is one translation layer per version that has to be maintained, and the obligation to test each contract separately. We decide differently when two versions have drifted so far apart in terms of domain logic that the translation would become more complicated than two separate paths — a rare case that usually means these are two different products, not two versions of one product.
Deprecation is a process, not an event
Switching off an old version is not a deadline but a sequence with lead time. It begins with an announcement, long before anything happens. It makes the deprecation visible in the protocol — via Deprecation and Sunset headers that tell every call that this version is ending, and when. And it relies on telemetry: you have to know who is still using the old version before you switch it off; otherwise you are switching blind.
Diagram: Between announcement and shutdown, both versions run — the Sunset header names the date.
| Phase | What happens | What the caller sees |
|---|---|---|
| Announcement | the end is communicated early | Deprecation header, changelog |
| Parallel operation | old and new run at the same time | Sunset header names the date |
| Observation | telemetry checks remaining usage | nothing — it keeps running |
| Shutdown | old is removed once barely used | the announced date takes effect |
The recommendation: announce deprecations early, make them machine-readable via Deprecation and Sunset headers, and only switch off once telemetry shows that the old version is no longer seriously used. The price is that you continue to operate, secure and test the old version over the entire window — a version you wanted to get rid of long ago stays around for months. We decide differently for an API without external users or with a small, known set of callers you talk to directly — there the window may be short, because the migration is coordinated rather than guessed.
Test contracts before the customer does
The most dangerous breaking change is the unintended one — the rename nobody recognized as breaking. The only defense is to test the contract itself. Contract tests check the promised shape of the response against every change; consumer-driven contracts let customers register their expectations, so that a break shows up in your own build, not in someone else's production.
The recommendation: secure the public contract with tests that break as soon as the promised shape changes. The price is a test suite you have to maintain and, with consumer-driven contracts, a degree of coordination with the callers. We decide differently for a very small, stable API with few known users — there a lightweight set of sample responses can suffice as a regression net instead of a full contract-testing setup.
Communication: changelog, deadlines, migration path
Technology alone doesn't break customers — poor communication does. Every visible change needs a changelog that clearly separates breaking from non-breaking changes. Every new version needs a migration path that shows step by step how to get from old to new. And every deprecation needs a deadline that is named early and kept. Known callers are additionally informed directly, not just via a header that perhaps nobody reads.
The recommendation: treat communication as part of the contract — changelog, migration guide and reliable deadlines. The price is ongoing effort: every change has to be described, every deadline maintained. We decide differently for internal APIs within a team, where a short notice is enough because the callers are known and reachable.
Common mistakes
The same patterns break customers over and over:
- Renaming or removing a field without recognizing it as a breaking change — the most common silent break.
- Tightening validation after the fact and assuming it is "just a fix" — for the caller, it is a break.
- Rolling out "version 2" and changing "version 1" in the process — a renamed break, not versioning.
- Cutting a new version for every small incompatibility until nobody knows which one applies.
- Building two versions as separate systems instead of translating at the edge — double maintenance, double bugs.
- Switching off without telemetry and hoping nobody uses the old version anymore.
- Announcing the deprecation only in the header and being surprised that customers are caught off guard.
- Keeping the old version "temporarily" until it becomes a permanent second interface.
Checklist
Questions a team can ask before every API change. They are diagnostic questions, not verdicts.
- Can an existing caller notice this change? If so, it is breaking — no matter how small it seems.
- Can the goal be achieved additively instead of changing what exists? Compatible evolution is almost always the cheaper path.
- If a new version is needed — does the old one remain valid and untouched? A version that changes the old one is just a renamed break.
- Does the core run version-agnostic, with translation only at the edge? Otherwise you will soon be maintaining two systems instead of two contracts.
- Do we know from telemetry who is still using the old version? Without this number, you are switching off blind.
- Are
DeprecationandSunsetheaders set, and has the deadline been communicated? Surprise is the real break. - Do we test the public contract automatically? Otherwise the customer learns about the break before we do.
- Is there a migration path an external team can follow on its own? If not, the migration depends on follow-up questions.
FAQ
Do we need versions at all if we stay compatible? Ideally, rarely. A well-designed API grows additively for years without a version bump. Versions are reserved for the moment when compatibility is no longer enough — not for ordinary evolution.
URL path or header — which is right? For public APIs, usually the URL path, because it is visible, cacheable and testable without explanation. Headers suit controlled internal clients, media type versioning suits fine-grained or hypermedia APIs. The price of each approach is in the table above; there is no winner without context.
How long do we have to operate an old version? Until telemetry shows that it is no longer seriously used, and at least as long as you announced. The number depends on your customers, not on a rule — but a deadline that is named early and then kept matters more than its exact length.
Is a new required parameter a breaking change? Yes. Anything that suddenly makes an existing, previously valid call fail is breaking — a new required parameter, stricter validation, a lower limit. New and optional with a sensible default doesn't break; new and required does.
What if a customer relies on undocumented behavior? Then they rely on it anyway, and their system breaks when you change it. That is why the safe assumption is that all observable behavior is part of the contract. You can phase out such dependencies over a deprecation period — but not by pointing out that it was never promised.
How do you run two versions without doubling the effort? Not as two separate systems, but as a version-agnostic core with a thin translation layer at the edge: the core knows no versions; each version translates at its boundary into the shared form. That way you maintain the domain logic once and only the translation twice — and that is exactly the difference between sustainable and unsustainable parallel operation.
Further reading
- Software that still runs in ten years — why contracts are the longest-lived part of a system.
- Legacy modernization without a big bang — the same principle of additive, reversible change, applied to entire systems.
- Designing a public API — the playbook for the concrete approach.
- Idempotency and Queue or synchronous processing — related decisions at the interface.
It is grounded in the Batunet Engineering Method: decide contracts deliberately, change in small reversible steps, rehearse failure beforehand.
A good API change goes unnoticed by the customer. New fields appear, old ones remain, versions end with notice — and the systems you never see keep running as if nothing had happened.
Referenced entities
Where this guide fits on the path.
Continue your engineering journey.
Related concepts, decisions, playbooks and perspectives — as one connected path, not a list of links.
Services
Engineering decisions
Playbooks
A concrete project in this field?
Reference Guides show how we think. For your system, talk to our management — technical, no sales pitch.
