Every API team eventually faces the versioning question: how do you evolve an API without breaking existing clients? The answers you’ll find online range from religious debates about URL paths versus headers to the bold claim that versioning is unnecessary.
Having maintained APIs across multiple major version transitions, here’s what we’ve learned about what actually works.
Why Versioning Gets Complicated
The versioning problem seems simple until you’re in the middle of it. You need to change something—rename a field, restructure a response, deprecate an endpoint—and suddenly you’re asking difficult questions:
- How many old versions do we support?
- How do we communicate changes to consumers?
- When can we sunset old versions?
- How do we test multiple versions?
- How much code duplication are we willing to accept?
These operational questions matter more than the technical mechanism you choose. URL versioning versus header versioning is a bike-shedding discussion compared to “how do we actually manage multiple live versions?”
The Common Approaches
URL Path Versioning
/api/v1/users
/api/v2/users
This is the most visible approach. Version numbers appear in the URL, making them impossible to miss. Clients explicitly request a version, and there’s no ambiguity about what they’re getting.
The good:
- Completely unambiguous
- Easy to test in a browser or curl
- Cache-friendly (different URLs, different cached responses)
- Simple to route at the infrastructure level
The bad:
- URLs aren’t supposed to identify representations (REST purists will object)
- Makes the version feel more significant than it might be
- Can lead to maintaining parallel codebases
Header Versioning
GET /api/users
Accept: application/vnd.myapi.v2+json
The URL stays clean; the version travels in a header. Some APIs use custom headers (X-API-Version: 2), others use the Accept header with vendor media types.
The good:
- URLs remain stable and “resource-focused”
- Versions are explicit but less prominent
- Fits REST’s content negotiation model
The bad:
- Harder to test casually (need to set headers)
- Not visible in logs without configuration
- Some clients struggle with custom headers
- Caching becomes more complex (Vary headers)
Query Parameter Versioning
/api/users?version=2
Version as a parameter, like any other option. Simple and explicit.
The good:
- Easy to use in any HTTP client
- Visible in URLs and logs
- Simple to implement
The bad:
- Clutters query strings
- Easy for clients to forget or omit
- Often treated as optional (what’s the default?)
No Explicit Versioning
Some APIs evolve without version numbers, relying on backward-compatible changes only. New fields get added; nothing gets removed or renamed. Clients ignore fields they don’t recognize.
The good:
- One codebase, one version to maintain
- Forces discipline around backward compatibility
- No version coordination with clients
The bad:
- Constrains what changes you can make
- Technical debt accumulates (deprecated fields live forever)
- Major breaking changes require a new API
Our Recommendation
For most APIs: URL path versioning with a commitment to backward compatibility within versions.
This isn’t the REST-purest approach, but it works:
- The version is explicit and visible
- Clients know exactly what they’re getting
- Infrastructure (load balancers, gateways, monitoring) handles it easily
- When you do need a breaking change, the path is clear
The key is treating version increments as significant events, not routine releases. Within a version, maintain backward compatibility aggressively.
Backward Compatibility Within Versions
The real versioning strategy is avoiding breaking changes whenever possible. Most API evolution can happen without version bumps:
Safe changes (no version bump needed):
- Adding new endpoints
- Adding optional request parameters
- Adding new fields to responses
- Adding new values to existing enums (if clients are built to ignore unknowns)
- Relaxing validation (accepting more input formats)
Breaking changes (require version bump or careful migration):
- Removing or renaming endpoints
- Removing or renaming response fields
- Changing data types
- Adding required request parameters
- Tightening validation (rejecting previously-accepted input)
- Changing authentication requirements
Design clients to be liberal in what they accept. Ignore unexpected fields. Handle missing optional fields gracefully. This robustness principle buys enormous flexibility for API evolution.
When You Need a New Version
Sometimes breaking changes are unavoidable. When that happens:
Make the new version genuinely better. If you’re going to force clients to migrate, the new version should offer clear improvements. Accumulate changes rather than nickel-and-diming clients with frequent version bumps.
Support the old version long enough. How long depends on your users and contracts. Enterprise APIs might need 12-24 months of overlap. Internal APIs might manage with weeks. Whatever you promise, honor it.
Provide migration tooling and documentation. A migration guide isn’t optional. Ideally, provide code examples showing the old way and the new way. Even better: tooling that helps clients update.
Communicate proactively. Deprecation notices, sunset timelines, changelog notifications. Surprises are relationship-destroying.
Handling Multiple Versions in Code
The painful part of versioning is maintaining multiple live versions. Strategies we’ve seen work:
Router-level branching. Different versions route to different handlers. This keeps version logic out of business code but can lead to duplication.
Adapter pattern. Core business logic is version-agnostic. Adapters translate between version-specific request/response formats and the core. New versions get new adapters; old adapters keep working.
Feature flags within a single codebase. Version detection happens early; conditional logic throughout. This gets messy but keeps everything in one place.
Separate deployables. Different versions are literally different services. Maximum isolation but significant operational overhead.
The adapter approach tends to age best for APIs with multiple major versions. It concentrates the version-specific code and keeps business logic clean.
Deprecation Done Right
Deprecation is the other half of versioning. How you communicate and execute deprecation matters:
Announce early. Tell people something is deprecated long before you remove it. Months, not weeks.
Include sunset dates. “Deprecated” is vague. “Deprecated, sunset December 2026” is actionable.
Warn in responses. Add deprecation headers or response metadata that clients can detect programmatically. Let their monitoring catch it.
Monitor usage. Track who’s still using deprecated endpoints. Reach out to high-volume users directly.
Actually sunset. If you never remove deprecated versions, you’re not managing versions—you’re accumulating them.
API Gateways and Version Management
API gateways (Kong, AWS API Gateway, Apigee) can help with versioning mechanics:
- Route different versions to different backends
- Transform requests/responses between versions
- Apply different rate limits or authentication per version
- Report usage by version
The gateway layer is also a natural place to return deprecation warnings for old versions.
Don’t let the gateway become a translation layer that hides version complexity from your services. The transformation logic still needs to be maintained; you’ve just moved it.
GraphQL and Versioning
GraphQL is often positioned as avoiding the versioning problem. New fields appear; deprecated fields eventually disappear; clients request exactly what they need.
This works when:
- You only make additive changes
- You deprecate carefully with long timelines
- Clients are well-behaved about ignoring deprecated fields
It breaks down when:
- You need to restructure the graph fundamentally
- Type definitions need breaking changes
- Client behavior is unpredictable
GraphQL shifts the problem rather than solving it. You’re still making versioning decisions; they’re just expressed differently.
What Actually Matters
After implementing various versioning approaches across multiple projects, here’s what we’ve found actually matters:
Consistency. Pick an approach and stick with it. Mixing URL versions, header versions, and query parameters is confusing.
Backward compatibility as default. Treat breaking changes as expensive. Most changes can be backward-compatible with thought.
Clear communication. Changelogs, deprecation notices, migration guides. Your versioning strategy is only as good as your communication around it.
Operational discipline. Supporting multiple versions has real cost. Plan for it. Budget for it. Eventually sunset old versions.
Client perspective. Everything looks different from the consumer side. Test your deprecation notices. Experience your migration path. Make it not painful.
The technical mechanism for versioning is the easy part. The hard part is the ongoing discipline of maintaining compatibility, communicating changes, and managing the lifecycle. Get that right, and any reasonable versioning approach will serve you well.