What are the main API versioning strategies (URI, header, content negotiation) and their tradeoffs?
Every API eventually breaks a contract with someone — versioning is simply deciding, in advance, how loudly and how often that happens.
LEARN BY DOINGPut your intuition to work3 steps
What are the main API versioning strategies (URI, header, content negotiation) and their tradeoffs?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Why an API needs a versioning story at all
Not every change to an API is a break. Adding a new optional field to a JSON response, adding a new endpoint, or adding a new optional query parameter are all additive — a well-written client ignores fields it doesn't recognize and keeps working. What actually requires a version bump is a breaking change: removing a field, renaming one, changing a field's type, changing the meaning of a status code, or changing required inputs. The entire point of a versioning strategy is to give you a mechanism for shipping a breaking change to some clients while the rest keep running against the contract they were built against, and a clear signal for when the old contract goes away.
#Strategy 1: URI versioning
The version becomes part of the path.
GET /v1/users/42GET /v2/users/42
// Express, side-by-side routersapp.use("/v1/users", usersRouterV1);app.use("/v2/users", usersRouterV2);
This is the most common strategy in public APIs (Stripe, GitHub, and Twilio all expose some form of it) because it is impossible to get wrong by accident: the version is visible in every log line, every browser address bar, and every curl command a developer pastes into a support ticket. Its cost is that it's not, strictly, "the same resource" anymore in REST terms — /v1/users/42 and /v2/users/42 are two different URIs for what is conceptually one user, which breaks the idea that a URI identifies a resource.
#Strategy 2: Header versioning
The path stays stable; a custom header carries the version.
GET /users/42 HTTP/1.1Host: api.example.comApi-Version: 2
app.get("/users/:id", (req, res) => {const version = req.header("Api-Version") ?? "1";if (version === "2") return getUserV2(req, res);return getUserV1(req, res);});
This keeps the URI stable and "pure" — one resource, one address — which plays nicer with caches keyed purely on the URL and with clients that treat the URL as the resource's identity. The tradeoff is discoverability: the version is invisible unless you already know to look for the header, it can't be tested by pasting a URL into a browser, and a shared HTTP cache that doesn't vary on that header (Vary: Api-Version) can silently serve the wrong version's response to the wrong client.
#Strategy 3: Content negotiation (media-type versioning)
The version is encoded in the Accept header as part of a custom media type — the approach GitHub's API popularized.
GET /users/42 HTTP/1.1Host: api.example.comAccept: application/vnd.example.v2+json
This is the "most RESTful" option on paper: HTTP already has a standard mechanism, content negotiation, for a client to say "give me this resource, but in this representation" — and a version is just another representation. In practice it has the weakest tooling support of the three: most people have never manually set an Accept header, it's awkward to test from a browser address bar, and API gateways and CDNs vary less reliably on Accept than on the path.
#Side by side
| Strategy | Visible in URL | Cache-friendly | Easy to test/curl | RESTful purity | Used by |
|---|---|---|---|---|---|
URI (/v1/...) | Yes | Yes (different URL = different cache entry) | Very easy | Low (same resource, different URI) | Stripe, Twilio, GitHub (legacy) |
| Custom header | No | Needs Vary header discipline | Moderate | Higher | Many internal/enterprise APIs |
| Content negotiation | No | Needs Vary: Accept discipline | Hardest | Highest | GitHub API (current) |
Query parameter (?version=2) | Yes | Yes, but pollutes cache keys with unrelated params | Very easy | Low | Some legacy/internal APIs |