API design · versioning · REST · backward compatibility · API deprecation
API Versioning Strategies: A Pragmatic Guide for Small Teams
A technical comparison of URL path, header, and content negotiation versioning approaches—and when each makes sense for small APIs where backward compatibility matters.
Published:
The versioning problem small teams actually face
Every API changes. The question isn’t whether you’ll need versioning—it’s how you handle it without turning your codebase into a maintenance graveyard.
For indie developers and small teams, the pressure to ship fast often collides with the reality that API consumers don’t update on schedule. A client library might be months behind. An internal dashboard might still call your v1 endpoints while you’re already running v3. Your job is to serve both without duplicating logic across three code paths.
This article examines the three mainstream versioning strategies—URL path versioning, header versioning, and content negotiation—and offers a practical framework for choosing between them.
URL path versioning: the default for a reason
The pattern is everywhere: https://api.example.com/v1/users, https://api.example.com/v2/users.
It’s the most common approach because it’s the most immediately understandable. Anyone can look at a URL and know which version they’re hitting. Testing in Postman requires no special headers. Documentation is straightforward.
But URL path versioning carries real costs.
It breaks REST semantics. A URI is supposed to identify a resource. When you embed version in the path, you’re treating different versions as different resources rather than different representations of the same resource. This isn’t just academic—it affects how you think about your API design and how clients discover endpoints.
It encourages legacy accumulation. Every version you add is a route you maintain. With path-based versioning, there’s no natural mechanism to signal that a version is deprecated. You end up with /v1/, /v2/, /v3/ all routing to active code, and the temptation to leave old versions running “just in case” grows with each release.
It creates coupling between version and endpoint structure. If you rename an endpoint in v2, you can’t keep the v1 path. Clients breaking on a rename have no graceful migration path—you’ve forced them to a new URL, which is a breaking change regardless of whether the response shape changed.
When path versioning makes sense: public APIs where discoverability matters, APIs consumed by non-technical stakeholders, or when you need clients to explicitly opt into a version because breaking changes are frequent and significant.
Header versioning: cleaner semantics, higher friction
Header versioning sends the version information in an HTTP header rather than the URL. The most common form is a custom header:
Accept: application/vnd.example.v1+json
Or a dedicated version header:
X-API-Version: 1
The advantage is semantic purity. The URI identifies the resource. The header identifies the representation. This aligns with how HTTP was designed—content negotiation has been part of the protocol since RFC 2616. You’re using the tool the protocol gives you for exactly this purpose.
The disadvantage is friction. Clients need explicit knowledge of which header to send. Debugging version issues requires inspecting request headers, not just the URL. Some middleware, CDNs, and logging tools strip or ignore custom headers, which can silently break version resolution.
Header versioning works best for sophisticated consumer bases—other engineering teams who understand HTTP semantics, SDKs that can manage header injection automatically, or internal APIs where you control both sides.
Content negotiation with vendor MIME types
This approach extends header versioning by using vendor-specific media types as defined in RFC 6838. Instead of application/json, you negotiate application/vnd.crowbar.v2+json or application/vnd.crowbar.v2.3+json.
The major.minor scheme described by the SCC Team at SUSE is particularly useful for small APIs. Minor versions indicate backward-compatible changes—new fields, new endpoints, optional parameters. Major versions signal breaking changes. This gives you a single version number that communicates compatibility expectations without requiring clients to parse changelogs.
The trade-off is complexity. Clients must understand MIME type negotiation. Your routing layer must parse and match vendor types. Some HTTP clients default to application/json and won’t send the vendor type unless explicitly configured. You’ll need to document the correct Accept header for every endpoint.
However, this approach has a structural advantage: it keeps versioning orthogonal to your URL design. You can restructure paths, rename endpoints, and add resources without touching the version scheme. The version lives in the content type, not the route.
GraphQL’s counter-example: versioning through schema evolution
GraphQL takes a different philosophical stance. The official guidance is clear: avoid versioning by designing for continuous schema evolution. Since clients request only the fields they need, adding a new field doesn’t break existing queries. Deprecating a field through the @deprecated directive lets consumers migrate at their own pace.
This works when you control the schema and your consumers use a typed client. It breaks down when you have heterogeneous consumers—some using the full schema, some using generated clients, some querying directly. The deprecation model requires discipline from both providers and consumers.
For small teams, GraphQL’s approach is worth studying even if you don’t adopt it. The principle—that you should design changes to be additive rather than substitutive—applies to REST APIs too. Before versioning an endpoint, ask whether you can add the change as a new field or endpoint rather than replacing the old one.
A practical deprecation policy
Regardless of which versioning strategy you choose, you need a deprecation policy. Without one, old versions accumulate indefinitely.
A minimal policy for small teams:
-
Announce deprecation before removing. Give consumers at least one major release cycle to migrate. Document the sunset date in your changelog and in response headers (
Sunset: <date>,Deprecation: <date>). -
Maintain only the two most recent major versions. Running three or more parallel versions multiplies your testing surface without proportional value. If a consumer is on v1 and v3 is current, they should migrate through v2.
-
Return warning headers on deprecated endpoints. A
Warning: 999 "Endpoint /v1/users is deprecated, use /v2/users instead"header gives clients programmatic visibility into deprecation without requiring them to parse documentation. -
Remove deprecated versions on a schedule, not a feeling. Six months is a reasonable minimum. Longer and you’ve created technical debt. Shorter and you’re breaking clients who are legitimately behind.
-
Track usage. If a deprecated endpoint has zero requests in 90 days, it’s safe to remove. If it still has traffic, extend the sunset date and communicate directly with the consumers.
Making the choice
There is no universally correct versioning strategy. The right choice depends on your consumers, your change frequency, and your tolerance for complexity.
Choose URL path versioning when:
- Your consumers include non-technical stakeholders or public developers
- You need maximum discoverability and minimal onboarding friction
- Breaking changes are rare and you want versioning to be visible
Choose header or content negotiation versioning when:
- Your consumers are engineering teams who understand HTTP
- You value semantic purity and want to keep URLs stable
- You anticipate frequent non-breaking changes that shouldn’t increment a major version
Choose GraphQL-style schema evolution when:
- You control the full stack and can enforce typed client usage
- Your change pattern is predominantly additive
- You want to eliminate versioning overhead entirely
For most small APIs, a pragmatic hybrid works best: path-based versioning for major releases with breaking changes, and content negotiation for minor version increments. This gives you the visibility of URLs for significant changes while preserving semantic cleanliness for incremental evolution.
The goal isn’t to avoid versioning. It’s to version in a way that makes deprecation manageable and migration predictable. Your future self—and your consumers—will thank you.
FAQ
Do I really need versioning if I’m careful about breaking changes?
If you never make breaking changes, you don’t need versioning. But “never” is a dangerous commitment. A changed field type, a removed optional parameter, or a reordered response array can break clients in ways you didn’t anticipate. Versioning is insurance. The question is how much insurance you need.
Can I version at the endpoint level instead of the API level?
Yes. Some APIs version individual endpoints rather than the entire surface. This is common in large APIs where different teams own different resources. For small APIs, endpoint-level versioning adds complexity without proportional benefit. Version the whole API unless you have a specific reason not to.
What about API keys and versioning?
API keys authenticate consumers; they don’t select versions. Keep these concerns separate. A consumer might hold a single key that accesses both v1 and v2 endpoints. Version selection should happen through the request (URL or header), not through the credentials.
How do I handle versioning in OpenAPI/Swagger documentation?
Document each versioned endpoint separately. If using path versioning, include the version segment in the path definition. If using content negotiation, document the expected Accept header in the request headers section. Don’t try to document all versions in a single spec file—that’s a recipe for confusion.
Sources
- Microsoft Docs: Web API Design Best Practices
- GitHub Community Discussion: RESTful API Versioning Approaches
- kirushik: API versioning for HTTP REST interfaces
- OAI/sig-moonwalk: Versioning is not always at the API granularity
- dwyl: Learn API Design
- Stack Overflow: Trade-offs between API URL construction methods
