How to Version an API Without Stranding Old Clients
An API version is not a number. It is a promise about what will keep working, and the moment that promise gets fuzzy, the number stops meaning anything. Plenty of teams learn this the hard way: they ship v2, put a polite deprecation banner on v1, and then spend the next eighteen months watching a handful of stubborn clients hammer the old endpoints because upgrading is expensive, risky, or simply not on anyone's roadmap. The version bump was easy. The stranding was the part nobody planned for.
The standard advice ("just use semantic versioning, add a sunset header, give six months") tends to collapse on contact with real client code. Client upgrades move at the speed of the client's own release cycle, which is often annual, sometimes frozen entirely. So the useful question is narrower: how does an API change its shape over time without leaving old integrations in a state where they break silently and nobody notices until a customer calls?
That question has real answers, and most of them are about discipline long before the version number ever changes.
The version number is the last decision, not the first
Most versioning disasters begin with a naming argument. Should this be v2 or v1.5? Is a new required field a minor change or a major one? Those arguments are symptoms of a deeper problem: the team has not decided which parts of the API are promises and which parts are implementation detail.
An API surface typically has three kinds of things in it. There are the parts clients genuinely depend on: endpoint paths, the meaning of status codes, the shape of the payload they parse, the auth flow. There are parts clients happen to depend on by accident: field ordering, error message text, the exact timestamp format, the order in which items come back. And there are parts nobody should touch: internal IDs, debug fields, anything marked experimental.
A version number only protects the first category. The second category is where clients get stranded, because a team changes an error string or reorders a list and considers it a harmless patch, and somewhere an integration that regex-matches on error text breaks in production. Semantic versioning cannot save a team from a contract it never wrote down.
The practical fix is to write the contract explicitly and treat it as the version boundary. If it is in the contract, it is a promise; changing it requires a new major version. If it is not in the contract, clients were never guaranteed it, and a changelog entry is enough. The hard part is that clients will still depend on undocumented behavior, which is why the contract has to be published, not just believed internally.
Additive changes still break clients that parse strictly
There is a comforting belief that adding a field is always safe. Add a new key to the response, old clients ignore it, everyone moves on. That belief holds for clients that deserialize into a permissive map. It fails for clients that validate against a strict schema, or that iterate over every key and render it somewhere, or that compute checksums over the full payload.
The failure mode is subtle because the server is behaving correctly. The response is valid JSON, the new field is documented, and the client's parser rejects it. Support gets a ticket that reads "API returns 500" when the API returned 200 and the client's own validation layer threw.
None of this means additive changes are wrong. It means the safety of an additive change depends on how clients consume the response, and the server team rarely has visibility into that. Two mitigations help. First, publish a machine-readable schema and version it alongside the API, so client teams can pin and diff. Second, when adding fields, consider whether they can be opt-in via a query parameter or a header for a release cycle, so strict clients are unaffected until they are ready.
That second approach adds complexity and is not free. But it converts a silent client break into a scheduled migration, which is the whole point of versioning in the first place.
Deprecation is a campaign, not a header
A Sunset header and a line in the changelog are the minimum, and they are close to useless on their own. Deprecation works when it behaves like a campaign with a schedule, named owners, and escalation, and it fails when it is a passive notice that the team hopes clients will read.
The useful sequence looks roughly like this. First, instrument the old endpoints and find out who is actually calling them, by client ID, by volume, by frequency. That data converts "we should probably deprecate v1" into "these four integrations account for 92 percent of v1 traffic, and one of them calls an endpoint we thought was dead." Second, communicate directly to those clients with a specific migration guide, not a generic blog post. Third, set a date and enforce it, with a real consequence at the end.
The consequence matters. A sunset date that passes with no enforcement trains every client to ignore the next one. A sunset date that turns off the endpoint, after ample warning and with a documented migration path, trains clients to take the next deprecation seriously. Teams that are unwilling to ever turn anything off should stop announcing sunsets, because an unenforced deadline is worse than no deadline: it teaches the entire client base that the API team's dates are decorative.
The migration path is the product
When clients do not upgrade, the usual diagnosis is inertia or laziness. The more accurate diagnosis is that the upgrade cost more than the client team could justify, and nobody on the API side had made that cost small. A major version bump that requires rewriting request construction, re-testing error handling, and re-certifying against a compliance process is not a two-hour task. It is a project, and projects get deferred.
So the API team's real deliverable in a version transition is not the new version. It is the migration path: a side-by-side comparison of old and new calls, a changelog that names the exact breaking changes, example requests and responses for each, and ideally a compatibility shim so clients can move one call at a time instead of all at once. A v2 that accepts v1-shaped requests for the first six months, translating internally, gets far more adoption than a clean v2 that requires a big-bang switch.
This is unglamorous work. It does not demo well. It is also the difference between a version transition that finishes and one that drags on for years while both versions get maintained, patched, and audited forever.
Parallel maintenance has a compounding cost
Every version that stays live is a version that has to be secured, monitored, and staffed. A single extra major version roughly doubles the surface for security patches, doubles the dashboards, doubles the on-call runbooks, and quietly consumes the team's capacity for new work. The cost is not visible in any single sprint. It shows up two years later as a team that cannot ship anything new because it is maintaining three generations of the same API.
The teams that avoid this treat version count as a budget. Two live major versions is normal. Three is a warning. Four is an emergency, and the response is not another deprecation notice, it is an actual migration project with engineering time allocated to helping clients move. The version number is a liability on the balance sheet, and it should be managed like one.
Version at the edge, not in the core
One structural decision prevents a surprising amount of stranding: keep version handling at the edge of the system, in a translation layer, and keep the core logic version-agnostic. When v1 and v2 requests both land on the same internal service, with an adapter translating each into a common internal shape, the cost of supporting v1 drops from "maintain a parallel codebase" to "maintain a thin adapter." The adapter is boring, testable, and cheap to delete when the last client leaves.
The alternative, forking the service per version, is what produces the three-generation maintenance problem. Forks drift. A bug fixed in v2 does not get fixed in v1 unless someone remembers to port it. The adapter approach makes the old version a compatibility concern rather than a second product.
There is a limit to this. Some changes are genuinely structural and cannot be translated cleanly, and forcing them through an adapter produces worse code than a fork. Those are the changes that justify a hard cutover with a real sunset date and a real migration budget. The point is to reserve that cost for the changes that need it, not to pay it on every release.
What clients actually remember is the last transition
Client teams form their opinion of an API vendor during version transitions, not during normal operation. A transition that gives clear dates, a real migration guide, working compatibility, and direct communication builds trust that survives the next three releases. A transition that announces a sunset with no enforcement, breaks a parser with an "additive" field, and then blames clients for not upgrading burns that trust for years.
The version number itself is almost irrelevant to that outcome. What matters is whether the team treated the contract as real, instrumented who was actually calling, made the upgrade cheap, and was willing to enforce the deadline it set. Old clients get stranded when any one of those four is missing, and they get carried forward when all four are present.
An API with a modest feature set and a disciplined transition process will keep more clients than a feature-rich API that surprises them. That is the whole trade, and it is decided long before anyone opens the version-number debate.

