API Versioning: Content Negotiation vs. URL Path Compared
AI generated
{ }
GET
API Versioning · Design Decision
API Versioning: Content Negotiation vs. URL Path
Why the choice of versioning strategy has more consequences than most teams initially assume

Once a REST API has to go through its first breaking change, the question of versioning strategy becomes unavoidable, and the three common approaches, URL path versioning, content negotiation via the Accept header, and a dedicated custom header, have fundamentally different effects on caching, client implementation, documentation, and actual usability for external integrators. Making the decision early and deliberately saves a painful, usually incomplete migration years later, once thousands of clients already depend on the originally, carelessly chosen structure.

15 min read API Versioning URL vs. Header

1. URL path versioning: simple, visible, but semantically debated

By far the most popular versioning strategy encodes the version directly in the URL path, such as /api/v2/orders instead of /api/orders, and is therefore immediately understandable to developers at a glance, without needing to consult documentation. This visibility also makes it practical for debugging and logging: a glance at the access logs immediately shows which API version is actually used by which client, without needing to evaluate headers separately.

The REST-theoretical objection to this strategy is that a URL, per REST principles, should identify a resource, not a protocol or representation version, meaning /api/v1/orders/123 and /api/v2/orders/123 formally represent two different resources instead of two representations of the same resource. In practice, the pragmatic benefit of visibility clearly outweighs this theoretical objection for most teams, which is why URL path versioning has remained the dominant standard on the web despite the criticism.

2. Content negotiation via the Accept header: REST-compliant, but invisible

The REST-theoretically cleaner alternative encodes the version inside the Accept header, such as Accept: application/vnd.example.v2+json, so the same URL is used for all versions of a resource, and versioning is actually treated as what it semantically is: a different representation of the same underlying resource. This approach consistently follows the original HTTP content negotiation principles and is used by some prominent APIs (such as GitHub in earlier API versions).

The practical downside is significant: the version is invisible to humans reading a URL, which complicates debugging, makes documentation more complicated (classic API documentation tools are often primarily designed around path-based structure), and many HTTP clients and test tools make manipulating custom media types harder than a simple URL change. These practical friction points explain why, despite its theoretical cleanliness, this approach is chosen less often in practice than URL path versioning.


<?php
declare(strict_types=1);

use Symfony\Component\HttpFoundation\Request;

final class AcceptHeaderVersionResolver
{
    private const VERSION_PATTERN = '/application\/vnd\.example\.v(\d+)\+json/';

    public function resolveVersion(Request $request): int
    {
        $accept = $request->headers->get('Accept', '');
        if (preg_match(self::VERSION_PATTERN, $accept, $matches)) {
            return (int) $matches[1];
        }
        return 1; // Default version if no explicit version is requested
    }
}

3. Custom header versioning as a pragmatic middle ground

A dedicated custom header like Api-Version: 2 combines some advantages of both previous approaches: the URL stays stable and resource-oriented like header-based content negotiation, while the version specification is at the same time easier to set and debug than a complex custom media type in the Accept header, since a simple value is used instead of nested MIME type syntax. Many modern APIs (such as Stripe) successfully use this approach in production.

The downside compared to URL path versioning remains: the version isn't visible in logs or when simply looking at a URL, which is an extra hurdle especially for external developers seeing an API URL in documentation or a forum post, since the actually used version can't be read directly from the URL.

4. How the versioning strategy affects HTTP caching

URL path versioning has a decisive practical advantage for caching: since every version has its own, distinct URL, standard HTTP and CDN caching works correctly without extra configuration, because different URLs are automatically cached separately. Header-based versioning, on the other hand, strictly requires a correctly set Vary header (Vary: Accept or Vary: Api-Version), since otherwise a cache would incorrectly serve one version's response to clients requesting a different version.

This caching difference is often overlooked in discussions about versioning strategies, but in practice it's often the decisive factor pushing teams toward URL path versioning, especially when a CDN or reverse proxy cache is in use that may not reliably or correctly be configurable to respect the Vary header.

5. Simple version number vs. full semantic versioning

Most REST APIs use a simple, sequential integer (v1, v2, v3) instead of full semantic versioning with Major.Minor.Patch, because external API consumers are typically only interested in breaking changes that require a deliberate migration, while non-breaking extensions (new optional fields, new endpoints) can be shipped without a version change within the same version anyway. This simplification significantly reduces the number of actively maintained versions compared to a full SemVer scheme with frequent minor version changes.

A full SemVer scheme pays off more for APIs whose clients are themselves libraries needing fine-grained dependency resolution (such as package manager ecosystems), while for typical HTTP REST APIs with human or application-side integrators, the simple, coarse version number is sufficient in practice and creates less maintenance overhead.

6. Running multiple versions in parallel within the same codebase

Regardless of the chosen versioning strategy, the application must internally decide how multiple simultaneously supported versions are represented in code, without completely duplicating business logic for every version. A proven pattern is to keep business logic version-independent and only differentiate the outermost serialization and validation layer per version, so a bugfix in the core logic automatically benefits all versions instead of needing to be separately maintained in each version.

For breaking changes that actually require different business behavior between versions (not just different representation), an explicit version branch in the application layer is unavoidable, but should be kept as narrow and clearly documented as possible, to avoid overloading the codebase with permanently growing version-branching logic.

7. A pragmatic recommendation for most teams

For the vast majority of public REST APIs with external integrators, URL path versioning, despite the theoretical REST criticism, is the pragmatically sensible choice, because of its visibility for debugging and documentation, its uncomplicated caching compatibility, and the lower implementation hurdle for client developers, who don't need special header manipulation tools. This recommendation applies especially to APIs used by a broad, technically heterogeneous integrator base.

Header-based approaches remain a legitimate choice for internal APIs with technically savvy, controlled consumers, or for teams wanting to consistently enforce REST principles and willing to accept the extra documentation and tooling effort that comes with this theoretically cleaner but practically more elaborate alternative.

8. How the versioning strategy affects migrating old clients

The chosen versioning strategy directly affects how easily an operator can later identify which clients are still working on an old version: with URL path versioning, this information can be extracted directly and reliably from standard access logs, without extra instrumentation, while header-based versioning requires dedicated logging of the respective header to be explicitly set up, which is easily overlooked in existing infrastructure.

This observability closely ties into the topic of sunset and deprecation headers: an operator who reliably knows which clients still use the old version can proactively reach out to them, instead of planning a retirement blindly and, in the worst case, unexpectedly breaking active integrations.

9. The three strategies compared side by side

The table below contrasts the key differences.

Strategy Visibility Caching
URL path (/v2/) High, directly visible Works natively without extra config
Accept header Low, invisible in URL Requires a correct Vary header
Custom header Low, invisible in URL Requires a correct Vary header
REST compliance URL path theoretically debated Header approaches theoretically cleaner

Mironsoft

OpenAPI design, Symfony APIs, and API security

APIs that external teams can integrate without back-and-forth questions?

We review existing REST APIs for inconsistent error formats, missing OpenAPI documentation, and security gaps, then build an API that is clearly documented, versioned, and hardened against abuse.

API Review

Checking the OpenAPI spec, error formats, and status codes for consistency.

Symfony Implementation

Using DTOs, Serializer, and Validator for clean, type-safe request/response models.

Security Audit

Hardening rate limiting, auth schemes, and input validation against real attack surfaces.

10. Summary

API Versioning: The Essentials at a Glance

URL path

Simple, visible, and cache-friendly, the dominant standard despite theoretical REST criticism.

Accept header

REST-theoretically clean, but invisible and associated with higher practical tooling overhead.

Custom header

A pragmatic middle ground with simpler syntax than the Accept header, but also invisible in the URL.

Recommendation

URL path for most public APIs, header approaches for controlled, technically savvy consumers.

11. FAQ: API Versioning: The Essentials at a Glance

1Can I switch versioning strategies later?
Technically yes, but it requires a full migration of all existing clients, which in practice is very effortful and rarely fully achievable.
2Should every small change trigger a new version?
No, only breaking changes justify a new version. Non-breaking extensions should be possible without a version change within the same version.
3How many versions should I support simultaneously?
As few as possible, usually two parallel active versions, combined with clear deprecation communication for the older one.
4Is GraphQL affected by this versioning problem?
GraphQL usually avoids classic versioning through additive schema evolution with @deprecated fields instead of separate versions.
5How do I combine URL path versioning with content negotiation for formats?
Both are independently combinable: /api/v2/orders with Accept: text/csv for format, separate from the API version in the path.
6What do I do if my framework poorly supports header versioning?
Symfony's routing supports header-based matching via conditions, but requires more manual configuration than plain path routing.
7Should v1 be explicit in the URL or is the unversioned URL v1?
Both conventions are common. Explicit v1 from the start avoids later confusion about what unversioned endpoints actually mean.
8How do I document multiple simultaneously active API versions?
With separate OpenAPI specifications per version, ideally with clear difference notes between versions in the documentation.
9Does the versioning strategy affect the choice of API gateway?
Yes, some API gateways route more easily by URL path than by headers, which can affect the practical implementation.
10Is versioning even necessary for internal, non-public APIs?
Often less critical, since internal consumers can usually be updated in a coordinated way. Still useful with many independent internal teams.