API Versioning Is a Deprecation Problem, Not a URL Problem

Table of Contents
- The Question Everyone Asks First
- Versions Are a Maintenance Commitment
- What Actually Breaks Clients
- Additive Change Avoids Versioning Entirely
- Where Version Information Belongs
- Deprecation That Works
- Knowing Who Uses What
- Handling Genuinely Breaking Changes
- Common Pitfalls
- Conclusion
- Frequently Asked Questions
Key takeaway: Every version you publish is a version you maintain until every client migrates. The strategy that matters is minimising how many versions exist, which means making changes additively and having a working deprecation process.
The Question Everyone Asks First
Design discussions about API versioning begin with mechanism. Path prefix, query parameter, custom header, or content negotiation. Arguments are made about purity and caching and discoverability.
This is the least consequential decision in the entire subject.
The consequential question is what happens after you publish version two. Now two versions exist. Both need maintenance — security patches, bug fixes, dependency updates. Both need testing. Both need documentation. Every subsequent feature needs a decision about whether it applies to both.
If a third version follows, the cost is not additive but combinatorial, because behaviour differences between versions interact with feature work.
Organisations that end up maintaining five API versions did not choose the wrong URL scheme. They published new versions without a working mechanism for retiring old ones, and versions accumulated.
The productive framing is therefore inverted: rather than asking how to version, ask how to avoid needing to, and how to retire a version once you do.
Versions Are a Maintenance Commitment
The full cost of a live version, which is routinely underestimated at the point of publication:
Code paths. Either duplicated implementations or conditional logic throughout. Both accumulate complexity, and conditional logic is where subtle version-specific bugs live.
Test surface. Every version needs its own suite. Integration tests multiply.
Documentation. Maintained per version, and drift between documentation and behaviour is worse than no documentation.
Support burden. Support staff must know which version a caller uses and how it differs.
Security patching. A vulnerability must be fixed in every live version, including ones nobody has touched in two years.
Feature decisions. Every new capability requires deciding which versions receive it, and inconsistency between versions confuses clients.
Data model constraints. Old versions frequently depend on database structures you would otherwise change. This is the cost that most constrains future work.
That last point is the one that hurts most over time. A version promising a response shape means the underlying data must remain able to produce that shape, which can block schema evolution for years.
Consider what a version costs before publishing it, not after. Many changes that seem to require a new version turn out to be achievable additively once the cost of the alternative is priced.
What Actually Breaks Clients
The distinction between breaking and non-breaking is more subtle than it appears, and getting it wrong is how teams break clients while believing they were safe.
Reliably breaking: removing a field, renaming a field, changing a field’s type, adding a required request field, removing an endpoint, making validation stricter, changing an error status code, changing the meaning of an existing value.
Usually safe: adding an optional request field, adding a response field, adding a new endpoint, adding a new enum value in a request, relaxing validation.
Depends on client implementation: adding an enum value in a response, which breaks clients that exhaustively match on known values. Changing field order, which breaks clients parsing positionally. Adding fields, which breaks clients with strict schema validation that rejects unknown properties. Changing pagination defaults. Changing response timing in ways that trip client timeouts.
That third category is where teams get caught. A change that is safe under a tolerant reader is breaking under a strict one, and you generally do not know which your clients implemented.
Two consequences follow. Document your compatibility contract explicitly — state that clients must ignore unknown fields and handle unknown enum values, so that adding them is defined as non-breaking. And test against a real client implementation, because your own client library may be tolerant in ways third-party consumers are not.
Additive Change Avoids Versioning Entirely
Most changes that appear to require a version bump can be made additively with some care.
Renaming a field. Add the new name, populate both, mark the old one deprecated in documentation, remove it after clients migrate. Two fields temporarily rather than two API versions permanently.
Changing a type. Add a new field with the new type alongside the old. The client chooses.
Restructuring a response. Add the new structure as an additional field rather than replacing the existing one. Unattractive and cheaper than a version.
Changing default behaviour. Introduce an opt-in parameter for the new behaviour, make it the default for new clients, and migrate existing clients explicitly.
Removing an endpoint. Deprecate, monitor usage until it reaches zero, then remove. No version needed if nobody is calling it.
Stricter validation. Log violations without rejecting them, contact the clients producing them, then enforce once the log is clean.
The pattern in all of these is a transitional period where both forms coexist, followed by removal once usage of the old form ends. That is more work per change than declaring a new version and considerably less work than maintaining a second version indefinitely.
The final removal step is what makes this work, and it depends entirely on knowing usage — which is the subject two sections down.
Where Version Information Belongs
Having established that the mechanism matters less, the options and their genuine trade-offs:
| Approach | Advantages | Disadvantages |
|---|---|---|
Path prefix /v2/resource |
Visible, cacheable, easy to route and test | Version applies to whole API; URLs change |
Header API-Version: 2 |
URLs stable, per-request granularity | Invisible in logs and browsers; caching needs Vary |
Query parameter ?version=2 |
Simple, visible | Pollutes URLs, awkward with caching |
| Content negotiation via Accept | Standards-aligned | Complex, poorly understood, easy to misimplement |
| Date-based version header | Fine-grained, clear ordering | Many versions to track internally |
Path prefixes are the most common choice for good practical reasons: they are visible in logs, trivial to route, straightforward to test, and immediately understandable to consumers. Their weakness is granularity — the version applies to the entire API, so a change affecting one endpoint bumps everything.
Date-based versioning, where clients pin to a date and every backward-incompatible change gets a dated entry, is worth understanding as an alternative. It gives fine granularity and clear ordering, and it requires internal machinery to transform between versions. Organisations that use it successfully build a transformation layer that upgrades old requests and downgrades new responses, so the core implementation only handles the current shape.
That transformation-layer pattern is the most valuable idea in this space regardless of mechanism: keep one internal implementation and translate at the boundary, rather than maintaining parallel implementations.
Deprecation That Works
Deprecation is the process that determines whether versions accumulate, and most implementations fail because they consist of a documentation note.
What actually moves clients:
Deprecation headers on responses. A Deprecation header with a date and a Sunset header with the removal date, on every response from a deprecated version. Machine-readable and visible to anyone inspecting traffic.
Direct contact with identifiable clients. Email to the teams whose keys are calling deprecated endpoints. Generic announcements are ignored; specific notice that your integration will break on a date is not.
A migration guide with the actual diffs. Not a description of the new version — the specific changes required, with examples.
A long, published timeline. Twelve months for widely-used public APIs. Shorter for internal consumers where you can coordinate directly.
Escalating friction rather than a cliff. Increasing warning prominence, then brief scheduled outages of the old version, then removal. Brief planned outages are extremely effective at surfacing clients who ignored every notice, and they do so while the outage is short and recoverable.
Usage-based enforcement. Remove when usage reaches near-zero rather than when the calendar says so, and be willing to extend for a small number of large integrators while removing for everyone else.
That scheduled-outage technique deserves particular mention. Announcing that the deprecated version will be unavailable for one hour on a specific date, then doing it, converts abstract deprecation notices into a concrete experience for the teams that were not reading them. Several large API providers use this and report it as their most effective migration tool.
Knowing Who Uses What
Everything above depends on knowing which clients call which endpoints. Without that, deprecation is guesswork and removal is dangerous.
The instrumentation required: per-request logging of the API version used, the specific endpoint, the authenticated client identity, and the client library version if available. Aggregated into a queryable view of usage per version per client.
What this enables that is otherwise impossible: identifying exactly who must be contacted before a removal. Confirming that usage has reached zero before removing. Distinguishing an endpoint used by one internal script from one used by a hundred customers. And detecting when a supposedly-migrated client falls back to the old version.
The absence of this data is why organisations maintain versions indefinitely. Removal without knowing usage risks breaking an unknown consumer, and that risk is correctly judged unacceptable, so nothing is ever removed.
Adding this instrumentation is a small piece of work with a large effect on your ability to retire anything. It is the highest-value investment in this entire subject.
A related practice: require client identification. Anonymous access makes it impossible to notify anyone, which makes removal permanently risky.
Handling Genuinely Breaking Changes
Some changes cannot be made additively — a fundamentally different resource model, a security fix requiring a contract change, or a data model change that the old shape cannot express.
When a version is genuinely required:
Publish the deprecation of the old version simultaneously with the new one. The retirement date should exist from day one. Versions published without a sunset date become permanent.
Build the transformation layer rather than parallel implementations. One internal model, translation at the edge. This is what keeps maintenance cost from multiplying.
Provide migration tooling, not only documentation. A tool that identifies required changes in client code, or a compatibility shim, moves clients faster than any guide.
Migrate your own clients first. It validates the migration path and identifies the awkward parts before external consumers encounter them.
Cap the number of live versions explicitly. A policy of at most two supported versions forces retirement before the next release. Without a cap, the number only grows.
That cap is the structural discipline that matters. It converts “we should retire v1 eventually” into “we cannot ship v3 until v1 is gone,” which is a constraint that produces action.
Common Pitfalls
Publishing a version without a sunset date. It becomes permanent.
Deprecation as a documentation note. Nobody reads it. Headers and direct contact move clients.
No per-client usage data. Makes removal permanently too risky to attempt.
Versioning for changes that could be additive. Trades a temporary duplicate field for a permanent second API.
Parallel implementations rather than a transformation layer. Multiplies maintenance and creates version-specific bugs.
Assuming clients tolerate new fields. Strict-schema clients break on additions unless your contract states otherwise.
No cap on live versions. The number grows monotonically.
Conclusion
The versioning mechanism is a minor decision. Path prefixes work well for most APIs and the choice barely affects the outcome.
What determines whether you maintain two versions or twelve is different. Make changes additively wherever possible, accepting temporary duplicate fields in exchange for avoiding permanent duplicate APIs. Document your compatibility contract explicitly, so that adding fields and enum values is defined as safe. Instrument per-client usage, because you cannot retire what you cannot see. Publish a sunset date at the same moment you publish a version. And cap the number of supported versions, so shipping a new one requires retiring an old one.
Then treat deprecation as an active process — response headers, direct contact with identified clients, migration tooling, and brief scheduled outages that turn ignored notices into concrete experience. Deprecation that consists of a note in the changelog is how versions become permanent.
Frequently Asked Questions
Which versioning mechanism should be used? Path prefixes for most APIs — visible, easy to route, easy to test. The choice matters far less than having a working deprecation process.
Is adding a response field a breaking change? Not under a tolerant-reader contract, and it does break clients with strict schema validation. State the expectation explicitly in your documentation so the answer is defined rather than discovered.
How long should a deprecated version be supported? Twelve months for widely-used public APIs, shorter for internal consumers you can coordinate with directly. Removal should be driven by usage reaching near-zero rather than by the calendar alone.
How many versions should be supported simultaneously? Two at most. A hard cap is what forces retirement; without it the count only increases.
Should internal and external APIs be versioned differently? Yes. Internal consumers can be coordinated directly, so much shorter timelines and additive-only changes are usually sufficient without formal versioning.
What about GraphQL? Field-level deprecation replaces whole-API versioning, which is a genuine improvement. The same underlying problem remains — you need usage data per field to know when removal is safe.
How can clients be moved off an old version? Sunset headers, direct contact with identified clients, migration tooling, and brief scheduled unavailability of the deprecated version. The last is uncomfortable and by far the most effective.



