Versioning & evolution
Every API that lives long enough has to change in ways the old shape did not anticipate. The whole craft of versioning is changing it without breaking the clients you have already shipped — clients you do not control, cannot redeploy, and often cannot even contact. The mechanism you reach for, a URL prefix or a header or a pinned date, matters less than two habits: knowing exactly what counts as a breaking change, and running an honest deprecation process when you finally have to make one. Most teams pick a versioning strategy in the first week and live with it for a decade, so it is worth picking on purpose.
The real problem: you cannot redeploy your clients
A function inside your own service is easy to change. You edit it, you update every caller in the same commit, the compiler tells you what you missed, and you ship the whole thing at once. An API is the opposite. Once a request shape and a response shape are published, anyone can write code against them, and that code keeps running on their schedule, not yours. A mobile app pinned to last year's response is sitting on a phone you will never touch. A partner's nightly batch job parses your JSON in a way you have never seen. Some integrations were written by a contractor who left, against documentation that is now stale, and nobody on either side remembers the details. The contract you published is the only thing holding it all together.
That asymmetry is the entire subject. Versioning is not really about version numbers; it is about managing change to a contract whose consumers you cannot see and cannot move. Everything below is in service of one goal: let the API keep growing while the code already written against it keeps working. When that goal is impossible to meet silently, versioning gives you a way to make the break explicit, opt-in, and slow enough that consumers can follow. This page is the companion to REST and API best practices; it assumes you already have a resource model and asks the next question, which is how that model is allowed to change.
Breaking versus additive: the line that decides everything
Before you can argue about strategies, you have to agree on what actually counts as a breaking change, because a versioning scheme is only ever paying for the breaking ones. A breaking change is anything that can make a correct, previously-working client start failing. An additive change is anything an existing client can ignore. The line is not always where people expect it, so it pays to be precise.
The conservative list of breaking changes — the ones that force a new version or a migration — runs like this. Removing or renaming a field, an endpoint, or a parameter. Changing the type of a field, including narrowing it, so that a value that used to be a free-form string is now a constrained enum. Adding a new required input, because every existing caller omits it. Changing the meaning or the units of a value while keeping its name and type, which is the nastiest kind of break because nothing in the schema signals it. Changing default behaviour, even when you are convinced you are fixing a bug, because deployed clients may have built workarounds on top of the old behaviour. And changing the cross-cutting machinery: the pagination scheme, the shape of the error envelope, the authentication mechanism, or the validation rules.
The interesting cases are the ones that look additive but are not. Adding a new value to an enum feels harmless, yet a client with an exhaustive switch over the old values, or a strict schema validator that rejects unknown members, will fall over the first time it sees the new one. Adding an optional field is the textbook safe change, and it usually is, but a generated SDK built against a closed schema — one that forbids extra properties — can reject the larger payload outright. The honest rule is that a change is additive only if your contract has explicitly promised that clients will tolerate it, and that promise has to be made up front, before anyone writes code against you. That promise has a name.
The tolerant reader principle
The single most valuable habit in API evolution costs nothing on the producer side and lives entirely in how consumers read responses. It is the tolerant reader principle, sometimes phrased as Postel's law: be conservative in what you send, and liberal in what you accept. A tolerant reader pulls out the fields it needs and ignores everything else. It does not validate that the response contains exactly the fields it expected and no others; it does not crash on an unknown enum value; it does not assume the order of a JSON object's keys; it treats absent optional fields as absent rather than as an error. A reader written this way survives almost every additive change without a single line of work.
This flips the economics of evolution. If most of your consumers are tolerant readers, then the whole category of additive change becomes free: you can add fields, add endpoints, and add enum values whenever you want, and nobody breaks. Versioning then only has to pay for genuine breaks, which are rarer, and you can afford a heavier process for each one. If your consumers are intolerant — strict schema validation, exhaustive matches, closed SDKs — then even adding a field is a breaking change, and you are forced to version far more often. The cheapest thing a producer can do for its own future is to document the tolerant reader contract loudly and to ship client SDKs that read tolerantly by default. Protobuf's wire format, covered on the Protobuf page, bakes this in: unknown fields are preserved and passed through rather than rejected, which is exactly why schema evolution there is so much smoother than with a strict JSON validator.
Semantic versioning, adapted for APIs
Semantic versioning gives you a three-part number, MAJOR.MINOR.PATCH, and a rule
for when each part moves. Bump PATCH for a backward-compatible bug fix. Bump
MINOR for a backward-compatible feature, which for an API means an additive change.
Bump MAJOR for any change that is not backward compatible, which for an API means a
breaking change. The whole value of the scheme is that the number itself tells a consumer
whether they can upgrade without thinking. Same major, higher minor: safe, you get new
capabilities for free. Different major: stop, read the migration guide, do work.
For HTTP APIs the practice usually collapses to the major number alone. You rarely see
/v1.4.2/ in a URL because consumers do not pin to a patch level of a remote
service the way they pin to a library version in a lockfile; they call whatever is live. So the
URL or header carries the major version, the one that signals a break, and the minor and patch
movements happen continuously and silently behind it, which is exactly what the tolerant reader
principle lets you do. The mental model worth keeping is that the major number is the only thing
a versioning mechanism has to express, because additive change is supposed to be
invisible. If you find yourself wanting to expose minor versions to clients, that is usually a
sign that your additive changes are not actually additive, and the fix is to make them tolerant
rather than to add more version numbers.
Where the version goes: four mechanisms
Once you accept that you will eventually make a breaking change, you have to decide where the version label lives. There are four common places, and they trade off the same handful of properties: how visible the version is in logs and curl, whether it interacts with HTTP caching, how cleanly it separates the resource identity from its representation, and how much server-side machinery it costs you. None is correct in the abstract; the right one depends on how often you break and how much you care about cache and logs.
?api_version=2, is really a less-visible cousin of the path and most teams add it only as a debugging fallback for the header approach.URI path versioning
Put the major version in the URL, as in /v1/charges/ch_001 and
/v2/charges/ch_001. AWS, Twilio, GitHub historically, and the overwhelming
majority of internal APIs do this. The appeal is bluntness. The version is visible in every
access log, every browser address bar, every curl example, and every screenshot in a bug
report, so nobody is ever confused about which version a request hit. It is impossible for a
misconfigured client to silently land on the wrong version, because there is no hidden default.
And it composes with HTTP caching at no cost: two versions live at two different URLs, so they
are two different cache keys automatically, no Vary header needed. The cost is that
each major version is conceptually a separate API surface, and you run those code paths in
parallel for as long as old clients exist. The scheme works best when versions are coarse — v1
for two years, then v2 — and you maintain only a handful at a time. It does not scale to many
small breaking changes, because nobody wants to think about /v17/.
Custom header versioning
Pass the version in a request header such as API-Version: 2. The URL space stays
clean: there is one canonical set of resource URLs, and the version is metadata about how you
want them rendered rather than part of their identity. Purists like this because, strictly,
/charges/ch_001 names one resource regardless of which representation you ask for,
and the version belongs in the request, not the path. The practical downside is that the
version disappears from logs and screenshots, which is real friction when you are debugging a
production incident at 2am and cannot tell from the access log which version a failing client
used. Header versioning also forces you to set Vary: API-Version so caches do not
serve a v1 response to a v2 request, and getting Vary right across a CDN is a
recurring source of subtle bugs. Most teams who choose header versioning end up adding a query
parameter fallback like ?api_version=2 precisely so the version reappears in logs
when they need it.
Media-type (Accept header) versioning
Encode the version in a vendor media type and select it with the Accept header, as
GitHub's REST API does with application/vnd.github.v3+json. This is the most
HTTP-native option. Content negotiation is the part of the protocol designed exactly for
"same resource, different representation," and a version of a response is a different
representation. It keeps URLs clean like the custom-header approach, and it reuses standard
machinery — the same Accept header that picks JSON over XML now also picks v3 over
v2. The cost is the same invisibility as custom headers plus a real ergonomics tax: the media
type strings are long and easy to mistype, casual clients that send Accept: */*
fall back to whatever your default is, and you still need Vary: Accept for caches.
It is the most architecturally principled choice and the least convenient one, which is why it
stays popular with API purists and rare almost everywhere else.
Date-pinned versioning
The most flexible scheme, made famous by Stripe, treats versions as a continuous timeline
rather than discrete steps. Each client pins to a calendar date — sent as
Stripe-Version: 2024-04-10 — and the server keeps every shape the API has ever had.
When a request arrives, the server produces its current, modern response and then runs it
backward through a chain of small transformers, one per published change, until the response
matches the shape that the client's pinned date expected. Renaming a field, for example, ships
a transformer that renames it back for any client pinned before the change. Consumers opt into a
break by moving their pinned date forward, one small step at a time, on their own schedule. It
is the most consumer-friendly model in existence, because every break is tiny and individually
opt-in, but it is also the most operationally expensive: you maintain that chain of
compatibility transformers more or less forever, and the chain only grows. The complexity pays
off only when your API is large enough that small breaking changes happen every few months. For
most APIs it is far more machinery than the problem deserves.
| URI path | Header | Media type | Date-pinned | |
|---|---|---|---|---|
| Visibility in logs | Strong | Weak | Weak | Weak |
| HTTP cache key | Free | Vary needed | Vary needed | Vary needed |
| Server-side cost | Code per major | Code per version | Code per version | Transformer per change |
| Granularity | Coarse | Coarse | Coarse | Fine |
| Used by | AWS, Twilio, most internal APIs | Atlassian, Microsoft Graph | GitHub REST | Stripe |
If you are unsure, default to URI path versioning. It is the most boring choice and the one your future on-call self will thank you for, because the version is always right there in the log line. Reach for date-pinning only when you have outgrown coarse versions, and reach for media types only when you have a strong reason to keep URLs pristine.
The deprecation playbook
Choosing where the version goes is half the problem. Retiring an old version is the other half, and it is the half teams skip, which is why so many APIs accumulate versions they can never kill. Removing something safely is a process, not an event. The version of it that works in practice runs in four phases, each of which gives the slowest consumers a louder signal than the last.
- Announce. Publish the deprecation with a firm removal date, and back it with
machine-readable headers so tooling can react. Send the
Deprecationheader to mark the endpoint as deprecated and theSunsetheader to carry the date it stops working, per RFC 8594. An SDK or a gateway can read these and warn at runtime, which reaches developers your changelog never will. - Track usage. Log every request to the deprecated version together with the calling client's API key, so you know exactly who is still on it and how hard. Sort by volume and reach out to the heaviest callers individually. Nothing moves a migration like a direct message from a real engineer naming the specific endpoint and date.
- Brown out. A few weeks before the cutoff, start returning errors for the deprecated version on a small, randomised slice of requests, and grow the slice over time — one minute every hour, then five, then fifteen. A brownout is loud enough that an active integration notices and quiet enough that it is recoverable, which is what finally shakes loose the long tail of clients that ignored every email.
- Cut off. On the announced date, return a permanent
410 Gonewith a clear message and a link to the migration guide. Keep that error response stable for a long time — at least a year — because clients you never reached will keep hitting the endpoint, and a helpful error is the only documentation they will ever see.
Expand and contract: how to change without a new version
The best break is the one you never have to ship as a version bump. The expand-and-contract pattern — also called parallel change — turns a single breaking change into a sequence of additive ones, so that at no single moment is anything actually broken. It works on APIs, on database schemas, and on any interface with consumers you cannot move in lockstep, and it is the technique that lets a well-run API stay on one version for years.
name field into first_name and last_name without a version bump. Add the new fields, keep the old one until traffic drains, then remove the old one through the deprecation playbook.The three phases are exactly what the diagram shows. In the expand phase you add
the new shape alongside the old one and keep both in sync, so the response now carries both
name and the new first_name / last_name pair. Nothing
breaks, because the old field still works and the new fields are additive. In the
migrate phase you move consumers over at their own pace — update your own SDKs to
read the new fields, document the change, and watch usage of the old field fall. In the
contract phase, once usage of the old field has drained to near zero, you remove
it through the standard deprecation playbook: announce, track, brown out, cut off. The genius of
the sequence is that the only breaking step, the removal, happens after almost nobody
depends on the old shape, which makes the break cheap. The same dance works on the request side:
accept both the old and new input shapes during the transition, prefer the new one when both are
present, and stop accepting the old one only after traffic has moved. Expand-and-contract is the
reason additive evolution can carry you much further than people expect before you ever need a v2.
Consumer-driven contract testing
All of this depends on knowing what your consumers actually rely on, and most of the time you do not. You know the fields you document; you do not know which of them a given client parses, which it ignores, and which it would crash without. Consumer-driven contract testing closes that gap. Each consumer writes down the exact requests it makes and the exact parts of the response it depends on, expressed as a contract — a set of concrete example interactions. Those contracts are collected and replayed against the provider in its own test suite, often through a broker that stores them. If a change to the provider would violate any consumer's contract, the provider's build fails before the change ever ships.
The shift in perspective is the point. Instead of guessing whether a change is breaking, you have a machine-checkable answer derived from what consumers use, not from what you happened to document. A contract suite will tell you that removing a field nobody reads is safe and that renaming a field three consumers depend on is not, and it will tell you at compile time rather than in a postmortem. It pairs naturally with the tolerant reader principle: a tolerant consumer writes a small contract that asserts only the few fields it needs, which means the provider stays free to change everything else. Inside an organisation where you can see all the consumers, this turns API evolution from a nervous judgement call into a normal, tested refactor. The schema-first tools on the Protobuf page give you a related guarantee from the other direction — the schema itself encodes which changes are wire-safe — and the two approaches reinforce each other.
GraphQL: deprecate fields instead of versioning the API
GraphQL takes a deliberately different stance: it has no API versions at all, and the official advice is to never version a GraphQL schema. The reasoning follows directly from how the protocol works. A GraphQL client asks for exactly the fields it wants, and the server returns exactly those and nothing more. Because clients are precise about what they request, adding a new field can never break anyone — no existing query mentions it, so no existing query is affected. The entire category of additive change is free by construction, which removes most of the pressure that drives REST APIs toward version numbers in the first place.
Removing or changing a field is still a real break, and GraphQL handles it at the field level
rather than the API level. You mark the old field with the built-in @deprecated
directive and a reason, which tells tooling, IDE autocomplete, and schema explorers to flag it.
You add the replacement field alongside it, watch the field-level usage metrics that GraphQL
servers expose, and remove the old field once no operation requests it. That is expand-and-contract
again, scoped to a single field and made first-class by the type system. The trade-off is that the
discipline moves from the producer to the schema and the tooling: you give up the blunt "everyone
on v2 now" lever, and in exchange you get evolution at the granularity of individual fields without
ever shipping a parallel API surface. It is the same goal as REST versioning — never break a
client — reached by making additive change the default and deprecation surgical.
When you can avoid versioning entirely
Putting it together, a surprising number of APIs never need a real version scheme, because they lean hard on tolerant readers and expand-and-contract. Two patterns carry most of the weight. The first is committing to additive evolution only: if you can promise to add but never remove or rename, you can keep one version alive for the life of the product. This works when the domain is mature and the resource shapes are stable, and it fails when you are still discovering the right model and have to walk back early mistakes. The second is per-field opt-in: ship a new field next to the old one, let clients move to it on their own schedule, keep the old field until its usage drains, then retire it through the deprecation playbook. Both patterns are just expand-and-contract applied as a way of life rather than a one-off migration.
The honest summary is that versioning is the fallback, not the goal. Reach for the tolerant reader contract first, evolve additively for as long as the domain allows, use expand-and-contract to turn the unavoidable breaks into sequences of safe steps, and lean on contract testing so you know which changes are actually breaking. Keep a real versioning mechanism in your pocket for the genuine, can't-avoid-it breaks, pick the mechanism deliberately because you will live with it for years, and when you finally do break something, run the deprecation playbook with the discipline it deserves. Do all of that and the promise at the top of the page comes true: the API keeps growing, and the code already written against it keeps working.
Further reading
- Stripe — APIs as infrastructure: future-proofing Stripe with versioning — the canonical writeup on date-pinned versioning and the transformer chain that powers it.
- RFC 8594 — The Sunset HTTP Header and the Deprecation header draft.
- GitHub — REST API versioning — a working example of date-based versioning layered over media types.
- Google AIP-180 — Backward compatibility — Google's internal rules on exactly what counts as a breaking change.
- Fowler — Consumer-Driven Contracts and Parallel Change (expand and contract).
- Semicolony — API best practices — the broader habits this page slots into.