09 / 11
Protocols / 09

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.

additive — safe{"id": "ch_001","amount": 4200,"currency": "usd", ← new field}old client ignores "currency" and keeps workingbreaking — must version{"id": "ch_001","amount" → "amount_cents","currency": "usd"}old client reads "amount", finds null, throwsadding is usually safe; removing, renaming, and re-typing are notA field a client never asked for cannot break it.A field it depends on, changed underneath it, always can.
The dividing line: additive changes leave existing fields alone, so old clients ignore the new parts. Breaking changes move ground the client is standing on.

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.

Write your contract down. State explicitly that clients must ignore unknown fields, must not break on new enum values, and must not depend on field order or on the absence of fields they do not use. A consumer that violates a documented tolerant-reader rule has broken the contract itself; a consumer that breaks on something you never promised has a fair complaint. The line between those two cases is the line you draw in the docs.

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.

1 · in the URI pathGET /v2/charges/ch_001visible everywhere · caches for free2 · in a custom headerGET /charges/ch_001API-Version: 2clean URLs · invisible in logs3 · in the Accept media typeAccept: application/vnd.example.v2+json
Three of the four mechanisms side by side. A fourth, a query parameter such as ?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 pathHeaderMedia typeDate-pinned
Visibility in logsStrongWeakWeakWeak
HTTP cache keyFreeVary neededVary neededVary needed
Server-side costCode per majorCode per versionCode per versionTransformer per change
GranularityCoarseCoarseCoarseFine
Used byAWS, Twilio, most internal APIsAtlassian, Microsoft GraphGitHub RESTStripe

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.

announceDeprecation +Sunset headerstrack usagelog callers,email the heavy onesbrown outfail a risingslice of requestscut off410 Gone+ migration linkmonths, not days — the long tail of clients moves slowly
The deprecation timeline. Each phase escalates the signal: a quiet header, then a personal email, then intermittent failures, then a permanent error.
  1. Announce. Publish the deprecation with a firm removal date, and back it with machine-readable headers so tooling can react. Send the Deprecation header to mark the endpoint as deprecated and the Sunset header 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.
  2. 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.
  3. 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.
  4. Cut off. On the announced date, return a permanent 410 Gone with 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.
The Sunset header is underused. It is one response header that says "this stops working on this date." Gateways, SDKs, and monitoring can all read it and surface a warning automatically. Set it the moment you mark anything deprecated. It costs nothing, and it turns your access logs into a live count of exactly who has not migrated yet.

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.

expand"name": "Ada L.""first_name": "Ada""last_name": "L."both shapes returnedmigrate"name": "Ada L.""first_name": "Ada""last_name": "L."clients move to new fieldscontract"first_name": "Ada""last_name": "L."old field removed at lastevery step is additive; the only break is removing what nobody uses anymore
Splitting a 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

Found this useful?