API Versioning
Versioning exists to protect clients from breaking changes. Classify changes with the decision helper, compare path, header, query and date-based versioning, and run a deprecation policy that clients can plan around.
🎯 What you'll learn
- Breaking vs non-breaking changes
- Path, header, query and date-based versioning
- Field-level evolution and what to version
- Deprecation policy, Sunset headers and gateway routing
📚 Before you start
An API is a promise to callers you often can't see or contact. The moment a client depends on a response shape, that shape is load-bearing — changing it breaks code you don't control and may never hear about until it's in production. Versioning is the set of conventions for making changes anyway, without breaking everyone who already integrated.
1Breaking vs. Non-Breaking Changes
The entire discipline rests on being able to classify a change correctly.
Non-breaking (safe to ship without a version bump):
- Adding a new optional field to a response
- Adding a new endpoint
- Adding a new optional request parameter with a sensible default
- Adding a new enum value, if clients are contractually required to handle unknown values gracefully
- Relaxing a validation rule (accepting more input than before)
Breaking (requires a version bump or a migration path):
- Removing or renaming a field
- Changing a field's type (
"id": 42->"id": "42") - Changing the meaning of a field without changing its name
- Adding a required request parameter
- Changing a status code an existing case returns
- Tightening validation (rejecting input that used to be accepted)
- Changing pagination defaults, sort order, or error shapes
// Before
{ "user": { "id": 42, "name": "Ada" } }
// Non-breaking: added field
{ "user": { "id": 42, "name": "Ada", "email": "ada@example.com" } }
// Breaking: type changed on an existing field
{ "user": { "id": "42", "name": "Ada" } }A useful test: could a client written against the old contract, with reasonable-but-not-paranoid parsing, keep working unmodified? If yes, it's additive. If the client would throw, silently misbehave, or receive different data under the same call, it's breaking.
2URI Path Versioning
The most visible and most common strategy — the version is baked into the URL:
GET /v1/orders/42
GET /v2/orders/42Pros: trivially cacheable per version (different URL = different cache key), visible in every log line and every curl command, trivial to route at the load balancer/gateway level to entirely different backend deployments.
Cons: the URI is supposed to identify a resource, and /v1/orders/42 and /v2/orders/42 arguably identify the same order — versioning the path conflates "which resource" with "which representation of the contract." REST purists consider this a layering violation. In practice it's the dominant approach anyway (Stripe, GitHub, Twilio) because operational simplicity usually wins.
3Header-Based Versioning
The URL stays stable; the version travels in a request header.
GET /orders/42
Accept: application/vnd.myapi.v2+jsonor a custom header:
GET /orders/42
Api-Version: 2026-03-01Pros: keeps the URI a pure resource identifier; the Accept header form uses content negotiation, which is what HTTP designed headers for in the first place.
Cons: invisible to a quick glance at logs or a browser address bar; harder to test ad hoc (curl needs an extra flag); intermediary caches and proxies key on URL by default, so header-based variance needs Vary: Api-Version to cache correctly at all.
4Query Parameter Versioning
GET /orders/42?version=2Rare as the primary mechanism (it's easy to omit accidentally, and mixes control metadata into the same namespace as filters), but common as a fallback or for quick manual testing alongside another scheme.
5Date-Based Versioning
Instead of integers, the version is the date the contract was frozen — Stripe's approach:
GET /v1/charges/ch_123
Stripe-Version: 2024-06-20Every account has a pinned default version (usually the date it was created), and it only moves forward when the integrator explicitly opts in by requesting a newer date. Internally the server keeps a table of transformation functions between consecutive dated versions and pipes an old request through however many transforms are needed to reach current internal logic, then pipes the response back down.
request (pinned: 2023-01-01) -> [transform 2023-01-01 -> 2023-06-01] -> [transform ... -> current] -> internal handler
response (current shape) -> [transform current -> ... ] -> [transform ... -> 2023-01-01 shape] -> clientThis scales far better than maintaining N parallel full implementations — there's one current implementation and a chain of small, independently testable shape-adapters.
6Semantic Field-Level Versioning
Instead of versioning the whole API, version individual capabilities via feature flags or field opt-ins:
GET /orders/42
X-Enable-Features: new-pricing-model,extended-statusThis avoids the combinatorial explosion of "v1 vs v2 vs v3 of the entire surface" when only one field actually changed, but it's harder to reason about which combination of flags a given client is actually running with, and testing the full cross-product of flags is often skipped in practice.
7What to Version: The Whole API vs. a Single Resource
Most real systems don't cleanly bump "the whole API" at once — a single breaking change to one resource doesn't require every other resource to also gain a /v2/. Two structures:
/v1/orders, /v1/customers, /v1/products (global version — simple, coarse)
/v2/orders, /v1/customers, /v1/products (per-resource — only orders changed)Per-resource versioning avoids forcing clients who only touch /customers to migrate when /orders changes, but it means "v1" and "v2" are no longer single coherent labels — the docs must state, per resource, which version is current.
8Deprecation Policy
A version bump is only half the story — the old version has to eventually go away, on terms clients can plan around.
GET /v1/orders/42 HTTP/1.1
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 31 Dec 2026 23:59:59 GMT
Link: <https://api.example.com/docs/migrating-to-v2>; rel="deprecation"Deprecation(a draft RFC header, widely adopted) marks a response as coming from a deprecated endpoint.Sunset(RFC 8594) gives the exact date the endpoint stops responding entirely.- A documented minimum notice period (commonly 6–12 months for public APIs) is a trust commitment, not just a courtesy — integrators build migration schedules around it.
A responsible sunset also actively measures usage of the old version before killing it — deprecation headers are easy to ignore, and traffic dashboards per version are the only reliable signal that migration has actually happened.
Under the HoodHow it works internally▾
Header-based versioning and the HTTP cache
Public/shared HTTP caches (CDNs, corporate proxies) key cache entries primarily by URL. Two requests to the identical URL /orders/42 with different Api-Version headers look, to a cache that only inspects the URL, like the same cacheable resource — so a cache will happily serve a v1 response to a v2 request (or vice versa) unless told otherwise. The fix is the Vary response header:
HTTP/1.1 200 OK
Vary: Api-Version
Cache-Control: max-age=60Vary: Api-Version tells any RFC-compliant cache "store a separate cached entry per distinct value of this request header." Without it, header-based versioning is silently broken behind any cache that predates your versioning header being invented — which is most of them, because Vary support for arbitrary custom headers is inconsistent across CDNs, and some ignore non-standard Vary values entirely. This is a real operational reason URI-path versioning remains popular: /v1/... vs /v2/... are different URLs, so they cache correctly for free, on every cache that has ever existed, with zero configuration.
The transform-chain pattern in detail
Maintaining N fully separate codebases for N versions means every bug fix and every new feature has to be back-ported N times, and N implementations drift out of sync with each other regardless of process discipline. The transform-chain approach (Stripe's actual documented architecture) keeps exactly one current implementation and models every past version as a pure, small, testable function from "one version's shape" to "the next version's shape":
type Transform = (payload: unknown) => unknown;
const transforms: Record<string, Transform> = {
"2023-01-01": (p: any) => ({ ...p, status: p.state }), // renamed state -> status later
"2023-06-01": (p: any) => ({ ...p, amount_cents: p.amount }), // amount -> amount_cents later
};A request pinned to 2023-01-01 runs the inverse of "2023-06-01"'s transform on the current handler's response (current -> 2023-06-01 shape -> 2023-01-01 shape), while a request already on 2023-06-01 skips straight to that step. Each transform is independently unit-testable in isolation, and the number of transforms in the codebase grows with the number of breaking changes ever made — not with the number of active client versions, because clients on the same version share the same chain segment. The real cost is that this chain only ever grows; retiring old transform steps requires actually sunsetting the versions that need them, which is exactly why deprecation policy (section 8) is not optional bureaucracy — it's what keeps the transform chain from becoming unbounded.
Why "just add a field" isn't always safe
Even purely additive changes can break a client that does strict deserialization. A client using a schema validator in additionalProperties: false mode, or a statically typed language deserializing into an exact struct with no #[serde(deny_unknown_fields)]-style opt-out, will reject or drop data the moment the server adds an unlisted field. This is why API contracts (see the API Documentation topic) should explicitly state "clients MUST ignore unknown fields" as a rule of the contract itself — it converts what would otherwise be a breaking change (from the client's accidental strictness) into a genuinely safe one, but only for clients that were actually built to that rule.
⚠️ Common Pitfalls
- No versioning strategy decided before the first breaking change is needed. Retrofitting a scheme onto a live API with existing integrators is far more disruptive than picking one (even url path, the simplest) on day one.
- Bumping the major version for non-breaking changes. This trains integrators to fear version bumps and ignore changelogs, because "v2" stops reliably signaling "you must act."
- Never actually removing old versions. A "deprecated" endpoint with no enforced sunset date accumulates forever, multiplying the surface area every future change must consider.
- Versioning too granularly (per-field flags) without a discovery mechanism. If clients can't query which flags exist and what they do, the feature-flag approach in section 6 becomes an undocumented maze.
- Silently changing behavior at the same version/URL. If
Api-Version: 2behavior is allowed to drift over time without a new version, "v2" stops being a stable contract at all — the version identifier only means something if it's frozen once published. - Forgetting
Varywith header-based versioning. As covered in Under the Hood, this causes cache poisoning across versions that is very hard to diagnose because it appears intermittent (depends on cache state). - No default/pinned version for new integrators. If omitting the version header silently means "whatever is newest today," a client's behavior can change out from under it the next time the API adds a version, with no code change on their end at all.
📋 Quick Reference
URI path /v1/orders simple, cacheable, visible; conflates resource + contract
Header Accept: application/vnd.api.v2+json clean REST; needs Vary; less visible
Query param ?version=2 rare as primary; easy to omit
Date-based Api-Version: 2026-03-01 Stripe-style; needs a transform chain
Classify first additive/optional -> safe | removed/renamed/retyped/tightened -> breaking
Deprecation Deprecation: true
Sunset: <RFC 1123 date>
Link: <...>; rel="deprecation"
Cache safety Vary: Api-Version REQUIRED for header-based versioning behind any cache
Contract rule "clients MUST ignore unknown fields" turns additive changes into safe ones
Transform chain one current impl + N small pure functions between consecutive versions🧠 Key takeaways
- Most changes can be additive; tell clients to ignore unknown fields (tolerant reader).
- Path versioning (
/v1) is explicit and cache-friendly; headers/dates (Stripe-style) allow finer evolution. - Version the whole API rarely; evolve fields with deprecation in between.
- Deprecate with
Deprecation/Sunsetheaders, docs, usage metrics and a published timeline.
🎤 Interview Q&A
Q1What versioning strategies exist?core▾
URL path (/v1/users): explicit, easy to route and cache. Header (Api-Version: 2) or media type (Accept: application/vnd.x.v2+json): cleaner URLs, less visible. Query param (?version=2). Date-based (Stripe: account pinned to an API version date).
- Most common? URL path versioning for public APIs.
Q2How do you avoid needing new versions?core▾
Evolve compatibly: add fields and endpoints, never remove or rename; make new fields optional; ignore unknown fields on input (tolerant reader); use feature flags or expand parameters.
- What's Postel's law here? Be conservative in what you send, liberal in what you accept.
Q3What counts as a breaking change?core▾
Removing or renaming fields/endpoints, changing types or formats, making optional parameters required, changing error codes or default behaviours, tightening validation, and changing authentication requirements.
- Is adding an enum value breaking? It can be for clients with exhaustive switches; document that enums may grow.
Q4How do you deprecate an API version?senior▾
Announce with timelines, add Deprecation and Sunset headers, monitor usage per client, contact heavy users, provide migration guides, and remove only after usage drops or the sunset date passes.
- How does Stripe handle versions internally? Request/response transformations chain the latest code back to older version shapes.
Q5How do you version internal (service-to-service) APIs?core▾
Prefer compatible evolution with contract tests; for breaking changes, run old and new versions side by side, migrate consumers, then remove the old one. Protobuf field numbering helps.
- Why fewer versions internally? You control the consumers and can coordinate migrations.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.