API Versioning & Deprecation
Every breaking change to a live API is a broken promise to somebody’s production system. This reference covers how to evolve an API contract without breaking existing clients — choosing a version identifier, running versions in parallel, signalling deprecation with standard headers, automating the changelog, and pinning generated SDKs — for backend engineers, API architects, and platform teams who own the contract and its downstream consumers.
Architecture Overview
Versioning is a contract concern, not a routing afterthought. The moment an API has one external consumer, its response shape, error semantics, and validation rules become a contract that cannot change silently. A disciplined versioning strategy answers four questions up front, and each maps to a topic in this reference:
- URL Path vs Header Versioning — where the version identifier lives, and how that choice affects caching, routing, and client ergonomics.
- Sunset & Deprecation Headers — how to signal, in-band and machine-readably, that a version or endpoint is going away and when.
- API Changelog Automation — how to derive an accurate, categorised changelog from the OpenAPI diff so breaking changes are never announced by accident.
- SDK Version Pinning — how spec versions map to generated client packages and the semver ranges consumers pin.
The decision that anchors all four is the definition of “breaking.” A change is backward compatible if every request that was valid against the old contract still succeeds, and every response the client could parse before still parses. Additive changes — a new optional field, a new endpoint, a new output enum value the client already tolerates — are safe. Removals, renames, tightened validation, changed defaults, and altered error codes are breaking and require a new major version.
The diagram below shows the lifecycle every major version travels: stable, superseded, deprecated, sunset, removed.
Old and new versions coexist for the entire deprecation window. There is no hard cutover — the gateway routes each request to the version its client asked for, and retirement happens only after traffic to the old version has drained. This is why versioning is inseparable from Error Contracts & Resilience Mapping: a client that keeps calling a removed version must receive a structured, actionable error, not a bare 404.
Canonical OpenAPI Spec Definition
The following OpenAPI 3.1 fragment anchors a versioned API. It declares the version in the servers block, documents the deprecation signalling on a legacy operation, and defines the response headers that carry the sunset contract. Each field is annotated with the decision it enforces.
# openapi.yaml — versioned surface (OpenAPI 3.1.0)
openapi: 3.1.0
info:
title: Orders API
version: 2.3.0 # semver of the SPEC document, not the URL major version
servers:
- url: https://api.example.com/v2
description: Current major version (URL-path versioning)
- url: https://api.example.com/v1
description: Deprecated — scheduled for sunset
paths:
/orders:
get:
operationId: listOrders
deprecated: true # surfaces in generated SDKs as an @deprecated annotation
description: >
Deprecated in favour of GET /v2/orders. This operation emits
Deprecation and Sunset response headers on every call.
responses:
"200":
description: A page of orders
headers:
Deprecation:
# RFC 8594 — presence (or an IMF-fixdate) marks the resource deprecated
schema: { type: string, example: "true" }
Sunset:
# RFC 8594 — the date after which the resource may return 410 Gone
schema: { type: string, format: date-time }
example: "Sat, 31 Oct 2026 23:59:59 GMT"
Link:
# rel="deprecation" and rel="successor-version" point clients to docs and the new URL
schema: { type: string }
example: '</v2/orders>; rel="successor-version"'
content:
application/json:
schema:
$ref: "#/components/schemas/OrderPage"
"410":
description: Version removed after its sunset date
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
components:
schemas:
OrderPage:
type: object
required: [data, api_version]
properties:
api_version:
type: string
const: "2" # echoes the served major version back to the client
data:
type: array
items: { $ref: "#/components/schemas/Order" }
The deprecated: true flag and the documented Deprecation/Sunset headers are the spec-level source of truth. SDK generators read deprecated to annotate the method; CI reads the servers block and the header schemas to enforce that no version is retired without a sunset contract.
Core Pattern 1: Choosing the Version Identifier
Where the version lives determines how clients select it and how you route it. The three mainstream strategies — covered in depth under URL path vs header versioning and media-type versioning with content negotiation — trade cacheability against granularity.
| Strategy | Example | Best when |
|---|---|---|
| URL path | GET /v2/orders |
Default choice — cacheable, greppable, gateway-routable |
| Custom header | X-API-Version: 2 |
You must keep one stable URL per resource |
| Media type | Accept: application/vnd.example.v2+json |
You version representations, not the whole surface |
Node.js — gateway routing by URL-path major version:
import express from "express";
const app = express();
// Each major version is a mounted router; both run in the same process
// during the deprecation window.
import { v1Router } from "./v1";
import { v2Router } from "./v2";
app.use("/v1", deprecationHeaders("2026-10-31T23:59:59Z"), v1Router);
app.use("/v2", v2Router);
// Middleware that stamps every v1 response with the sunset contract.
function deprecationHeaders(sunsetIso: string) {
const sunsetHttpDate = new Date(sunsetIso).toUTCString();
return (_req: express.Request, res: express.Response, next: express.NextFunction) => {
res.setHeader("Deprecation", "true");
res.setHeader("Sunset", sunsetHttpDate);
res.setHeader("Link", '</v2/orders>; rel="successor-version"');
next();
};
}
Python — FastAPI sub-application per major version:
from fastapi import FastAPI, Response
app = FastAPI()
v1 = FastAPI()
v2 = FastAPI()
SUNSET = "Sat, 31 Oct 2026 23:59:59 GMT"
@v1.middleware("http")
async def add_deprecation(request, call_next):
response: Response = await call_next(request)
response.headers["Deprecation"] = "true"
response.headers["Sunset"] = SUNSET
response.headers["Link"] = '</v2/orders>; rel="successor-version"'
return response
app.mount("/v1", v1) # deprecated surface
app.mount("/v2", v2) # current surface
Mounting one router per major version keeps handler code isolated: a bugfix on v2 cannot regress v1, and deleting the version at sunset is a one-line unmount plus a router removal.
Core Pattern 2: Signalling Deprecation In-Band
A changelog entry that nobody reads does not stop a client from breaking. The reliable channel is the response itself. Sunset and deprecation headers let a client’s HTTP layer detect, log, and alert on deprecation automatically — long before the sunset date arrives. The definitive implementation of the header, its date format, and the 410 Gone follow-through is covered in implementing the Sunset HTTP header.
TypeScript — client interceptor that surfaces deprecation warnings:
// A fetch wrapper that logs and forwards deprecation signals to observability.
export async function versionAwareFetch(url: string, init?: RequestInit): Promise<Response> {
const res = await fetch(url, init);
const deprecation = res.headers.get("Deprecation");
const sunset = res.headers.get("Sunset");
if (deprecation) {
const successor = parseLinkRel(res.headers.get("Link"), "successor-version");
console.warn(
`[api] ${url} is deprecated` +
(sunset ? `, sunset ${sunset}` : "") +
(successor ? `, migrate to ${successor}` : ""),
);
// Emit a metric so a dashboard can track deprecated-endpoint call volume.
metrics.increment("api.deprecated_call", 1, { endpoint: new URL(url).pathname });
}
return res;
}
function parseLinkRel(header: string | null, rel: string): string | null {
if (!header) return null;
const match = header.split(",").find((part) => part.includes(`rel="${rel}"`));
return match ? match.slice(match.indexOf("<") + 1, match.indexOf(">")) : null;
}
Turning the Deprecation header into a metric means a platform team can watch deprecated-endpoint traffic fall over the deprecation window and hold the sunset date only until the curve reaches zero — an evidence-based retirement rather than a guess.
Core Pattern 3: Deriving the Changelog from the Spec
Humans forget to document breaking changes; a diff of the OpenAPI document does not. API changelog automation runs a structural comparison between the previous and current spec and classifies every difference as breaking, non-breaking, or informational — the mechanics are detailed in generating API changelogs from OpenAPI diffs.
Bash — fail CI when an unversioned breaking change appears:
#!/usr/bin/env bash
# compare the spec on main against the PR branch and block breaking diffs.
set -euo pipefail
npx @useoptic/optic diff \
openapi.base.yaml openapi.head.yaml \
--check --fail-on breaking > changelog.json
# Categorised counts for the PR comment
breaking=$(jq '[.changes[] | select(.severity=="breaking")] | length' changelog.json)
if [ "$breaking" -gt 0 ]; then
echo "::error::${breaking} breaking change(s) require a major version bump"
exit 1
fi
Python — categorise a raw diff into a human changelog:
import json
from pathlib import Path
SEVERITY_LABELS = {
"breaking": "### Breaking changes (major bump required)",
"addition": "### Added (backward compatible)",
"info": "### Notes",
}
def render_changelog(diff_path: str) -> str:
changes = json.loads(Path(diff_path).read_text())["changes"]
sections: dict[str, list[str]] = {k: [] for k in SEVERITY_LABELS}
for c in changes:
bucket = c["severity"] if c["severity"] in sections else "info"
sections[bucket].append(f"- `{c['location']}` — {c['description']}")
out = []
for key, header in SEVERITY_LABELS.items():
if sections[key]:
out.append(header + "\n" + "\n".join(sections[key]))
return "\n\n".join(out)
An automated changelog is only trustworthy if it is generated from the same artifact the SDKs are generated from — the OpenAPI document — so the changelog and the client code can never drift apart.
CI/CD Enforcement
Versioning discipline lives or dies in the pipeline. The workflow below blocks a merge when a breaking change lands without a major version bump, and verifies that every deprecated operation carries a Sunset header.
# .github/workflows/versioning-ci.yml
name: Versioning Contract CI
on:
pull_request:
paths: ["openapi.yaml"]
jobs:
version-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Diff spec for breaking changes
run: |
git show origin/main:openapi.yaml > openapi.base.yaml
npx @useoptic/optic diff openapi.base.yaml openapi.yaml \
--check --fail-on breaking
- name: Assert major version bumped when breaking
run: node scripts/assert-version-bump.js
- name: Lint deprecation contract (Spectral)
run: npx @stoplight/spectral-cli lint openapi.yaml --ruleset .spectral.version.yaml
Spectral ruleset — every deprecated operation must declare a Sunset header:
# .spectral.version.yaml
extends: ["spectral:oas"]
rules:
deprecated-needs-sunset:
description: "A deprecated operation must document a Sunset response header"
given: "$.paths.*[?(@.deprecated == true)].responses.200.headers"
severity: error
then:
field: "Sunset"
function: truthy
server-declares-version:
description: "Each server URL must carry a /vN major-version segment"
given: "$.servers[*].url"
severity: error
then:
function: pattern
functionOptions:
match: "/v[0-9]+$"
These gates align with the linting practices in Contract Linting & Governance: the same Spectral engine that enforces naming and response conventions also enforces the versioning contract.
SDK and Client Impact
A major API version and a major SDK version are two views of the same contract. When the spec’s servers major version changes, the generator emits a new client package, and consumers select it through a pinned semver range — the full mechanics are in SDK version pinning and semver ranges for generated SDK clients.
TypeScript — a generated method carries the deprecated flag through to the IDE:
export class OrdersV1Api {
/**
* @deprecated Use OrdersV2Api.listOrders. Sunset 2026-10-31.
* Generated from `deprecated: true` in the OpenAPI operation.
*/
async listOrders(query: ListOrdersQuery): Promise<OrderPage> {
return this.http.get("/v1/orders", { query });
}
}
Go — build-time selection of the pinned major version:
// go.mod pins the generated SDK to a compatible major version.
// require github.com/example/orders-go/v2 v2.3.0
import orders "github.com/example/orders-go/v2"
client := orders.NewClient("https://api.example.com/v2")
page, err := client.ListOrders(ctx, orders.ListOrdersParams{Limit: 20})
Because Go encodes the major version in the import path (/v2), a client cannot accidentally compile against two incompatible majors at once — the versioning contract is enforced by the toolchain. In npm and PyPI the same guarantee comes from a caret range plus a lockfile.
Edge Cases and Anti-Patterns
| Anti-pattern | Recommended approach |
|---|---|
| Shipping a breaking change under the same version | Bump the major version; run both in parallel behind the gateway |
| Removing a version with no notice | Emit Deprecation and Sunset well ahead; return 410 Gone only after the sunset date |
Versioning every additive change (/v2, /v3, /v4 in a month) |
Add optional fields and endpoints in place; reserve majors for breaking changes |
Sunset as a bare date with no timezone |
Use an RFC 7231 IMF-fixdate (GMT) so all clients agree on the instant |
| Version signalled only in docs | Signal in-band via headers or URL so client HTTP layers can react automatically |
| SDK version decoupled from API version | Map each API major to an SDK major; pin with a caret range and a lockfile |
| Deleting old handler code at cutover | Isolate versions in separate routers so retirement is a clean removal |
| Announcing breaking changes by memory | Generate the changelog from the OpenAPI diff in CI |
FAQ
Should a new API version live in the URL path or in a header?
Use URL-path versioning (/v2/) as the default: it is cacheable, greppable in logs, and trivial to route at the gateway. Reserve header or media-type negotiation for APIs that must keep a single stable URL per resource, or that version individual representations rather than the whole surface.
When does a change require a new major API version?
Bump the major version only for breaking changes: removing or renaming a field, tightening a type or validation rule, changing a default, or altering error semantics. Additive changes — new optional fields, new endpoints, new output enum values — are backward compatible and ship without a version bump.
How long should a deprecated version stay available before sunset?
Announce deprecation with a Deprecation header and a Sunset date at least one to two release cycles ahead — commonly 90 to 180 days for public APIs. Track active clients by SDK version and hold the sunset until traffic falls below an agreed threshold.
How do versioning decisions affect generated SDK clients?
Each major API version maps to a generated SDK whose package version is pinned with a semver range. Breaking spec changes force a major SDK bump; additive changes ship as minor releases. Consumers pin a caret range so they receive compatible fixes without silently adopting a breaking version.
Related
- URL Path vs Header Versioning — cacheability, routing, and client ergonomics of each identifier strategy
- Sunset & Deprecation Headers — RFC 8594 signalling, timelines, and the 410 Gone follow-through
- API Changelog Automation — categorised changelogs generated from the OpenAPI diff
- SDK Version Pinning — mapping API majors to SDK packages and semver ranges
- Contract Linting & Governance — the Spectral and Redocly rules that enforce versioning and deprecation contracts
- Error Contracts & Resilience Mapping — how a client should react when it calls a removed or deprecated version