API versioning is one of those problems that feels trivial until your third breaking change is quietly taking down a production integration somewhere you have no visibility over. Whether you are building internal microservices, a public developer platform, or the data layer for a mobile app, the way you version your APIs shapes how much pain you carry forward with every release.
There is no universal right answer here. The best strategy depends on your consumers, your release cadence, and how much operational complexity your team can absorb. What follows is a practical guide to the main approaches, when each one makes sense, and what to watch out for.
Why versioning matters more than most teams expect
APIs are contracts. When a consumer integrates against your API, they build assumptions about structure, behaviour, and stability directly into their code. Break those assumptions without warning and you break their systems. The challenge is that APIs need to evolve: requirements change, security issues surface, data models get refactored. Versioning is the mechanism that lets you evolve while honouring existing contracts.
The hidden cost is on the provider side. Every version you maintain is a version you have to test, monitor, and eventually deprecate. Teams that version too aggressively end up running a small museum of old API surfaces. Teams that do not version at all end up paralysed, afraid to change anything. The goal is a strategy that gives you room to move without creating unsustainable operational debt.
The main versioning strategies
URI versioning
URI versioning puts the version number directly in the path: /v1/orders, /v2/orders. It is the most visible and the most common approach in public APIs. Consumers can see exactly what they are calling. Routing is trivial. You can run multiple versions behind the same load balancer without any header inspection.
The trade-off is that it can lead to version proliferation and it technically violates the REST principle that a URI should identify a resource, not a resource at a point in time. In practice, most teams accept this violation because the operational simplicity is worth it. URI versioning is a sensible default for any API with external consumers who you cannot easily coordinate with.
Header versioning
Header versioning keeps the URI clean and puts the version in a request header, typically a custom one like API-Version: 2 or using the Accept header with a media type (application/vnd.yourapi.v2+json). This is the approach favoured by teams who want to preserve URI stability and align with stricter REST semantics.
The downside is discoverability. A URI is self-documenting in a way a header is not. Debugging in a browser or with a basic curl command is less intuitive. Caching behaviour can also be affected if proxies and CDNs do not vary on the custom header correctly. Header versioning works well for APIs consumed by disciplined teams using well-configured HTTP clients, but it adds friction for developers exploring your API for the first time.
Query parameter versioning
Query parameter versioning appends the version to the URL as a parameter: /orders?version=2. It is easy to implement and easy to test in a browser. It tends to appear in older APIs and in certain public data APIs where simplicity is the priority.
It shares the semantic objections of URI versioning without the routing clarity, and it can be accidentally stripped by proxies or caching layers that normalise query strings. Most teams that have a choice pick URI versioning over query parameters for anything consumer-facing.
Semantic versioning for APIs
Borrowed from package management, semantic versioning communicates the nature of a change through the version number itself: major versions for breaking changes, minor for additive changes, patch for fixes. Applied to APIs, it usually means only major versions are surfaced in the URI or header, with minor and patch changes happening transparently within a version.
This approach pairs well with a clear definition of what constitutes a breaking change. Removing a field is breaking. Adding a new optional field is not. Changing a field type is breaking. This discipline forces useful conversations during design review and reduces the frequency of major version bumps. The choice between REST and GraphQL can influence how naturally semantic versioning fits your API surface, since GraphQL's schema evolution tools handle additive change differently to REST.
Versioning through content negotiation
Content negotiation uses the Accept and Content-Type headers to negotiate representation format, including version. A client requests application/vnd.yourapi+json; version=3 and the server responds accordingly. This is the most RESTfully pure approach and is used in APIs like GitHub's v3.
It is also the most complex to implement and document correctly. Most teams without a strong REST purist culture find it adds more confusion than clarity. It is worth knowing the pattern exists, but only adopt it if your team has the discipline to implement and communicate it consistently.
Deprecation: the strategy nobody plans properly
Version strategy and deprecation strategy are inseparable. Every version you create is a commitment to support it for some period. Without a deprecation policy, old versions accumulate indefinitely.
Good deprecation practice involves three things. First, a published timeline: tell consumers when a version will be retired and give them enough lead time to migrate. For external APIs with many integrators, twelve months is a reasonable minimum. Second, active signalling: use Deprecation and Sunset HTTP response headers to surface deprecation warnings programmatically. Consumers who instrument their HTTP clients will see these before they read your changelog. Third, migration guides: do not just tell developers a version is going away; show them exactly what changed and provide tooling where possible.
Teams that tie version deprecation to their CI/CD pipeline see better results. If you are refining your CI/CD pipeline, adding automated checks that warn when a client is calling a deprecated API endpoint is a low-effort, high-value addition.
Choosing the right strategy for your context
A few questions sharpen the decision quickly. How many external consumers do you have, and how easily can you coordinate with them? If the answer is "not easily," URI versioning with a generous deprecation window is usually the safest choice. Can you define breaking change precisely and enforce it through tooling? If yes, semantic versioning with minor-version transparency becomes viable. Is REST purity a genuine team value backed by tooling and documentation standards? If yes, header or content negotiation versioning may be worth the added complexity.
Internal microservices teams often benefit from a lighter approach. If all consumers are internal and your deployment is coordinated, you can move faster with a contract-testing approach (using tools like Pact) rather than maintaining parallel API versions at all. Contract tests give each service team confidence that a change will not break its consumers without requiring you to run multiple live versions simultaneously.
Practical principles that apply to every strategy
Regardless of which versioning model you adopt, a handful of principles hold across all of them. Be additive by default: adding fields, endpoints, and optional parameters is rarely breaking. Design for extensibility from the start, using patterns like the envelope response format that leave room for metadata without changing the core payload structure. Document version differences explicitly and keep that documentation up to date. Version your OpenAPI or AsyncAPI specs under source control alongside your code. And treat your changelog as a first-class artefact, not an afterthought.
API versioning is ultimately an act of respect toward your consumers. The teams who get it right are not the ones who never make breaking changes. They are the ones who communicate clearly, deprecate thoughtfully, and give integrators the runway they need to keep up.
