API versioning is a promise: “clients integrating today won’t wake up broken tomorrow.” The trick is that version numbers don’t save you—compatibility discipline does. This guide shows practical rules, rollout patterns, and a clean deprecation process so you can evolve APIs safely without breaking clients (or your sanity).
Quickstart
If you want API versioning without regret, start here. These steps are the highest leverage because they prevent breaking changes long before you argue about whether to version in URLs, headers, or media types.
1) Write down your public contract
Versioning is about the parts clients depend on. Make those explicit.
- Document request/response shapes, required fields, and error formats
- Define what “breaking” means for your API (examples below)
- Commit to a stability window (e.g., “v1 supported for 12 months after v2”)
2) Adopt a default: “additive changes only”
Most product evolution can be additive. Add fields, don’t remove them.
- Add new fields as optional (with safe defaults)
- Add new endpoints rather than repurposing old ones
- Expand enums carefully (and make clients resilient)
3) Pick one versioning surface (and stick to it)
The fastest way to confusion is mixing URL, headers, and query params across teams.
- Public REST? Choose path versioning (
/v1) or media types (vendorAccept) - Internal APIs? Prefer header-based or gateway routing
- Version docs and SDKs the same way you version the contract
4) Plan deprecation like a rollout, not an announcement
Good deprecation is measurable, gradual, and reversible.
- Add deprecation headers + migration guides
- Instrument usage by client (who is still on the old version?)
- Give a sunset date and enforce it after the window
Teams don’t break clients because they love chaos—they break them because “small” changes ship without a compatibility check. Build a habit and a gate (linting, contract tests, review checklists). The version number becomes a safety net, not the plan.
Overview
APIs are long-lived interfaces between independent release cycles. Your server can deploy ten times a day; your clients might update once a month, or never (hello, enterprise). That gap is why API versioning exists: it lets you evolve the server while keeping older clients functional.
What this post covers
- Compatibility rules (what counts as “breaking” in practice)
- Versioning surfaces (URL vs headers vs media types — tradeoffs)
- Rollout & deprecation (how to migrate safely and remove old versions)
- Operational guardrails (tests, monitoring, and documentation patterns)
You’ll notice a theme: versioning is a last resort. You use it when you truly must change the contract in a way that will break existing clients. The rest of the time, you evolve through additive changes, sensible defaults, and stable semantics.
| Approach | How it looks | Best for | Watch out for |
|---|---|---|---|
| Path versioning | /v1/users |
Public REST APIs, human-friendly docs | Duplicates endpoints; can encourage “just make v3” |
| Header versioning | X-API-Version: 2 |
Internal APIs, gateways, gradual migrations | Harder to test manually; proxies/caches must vary correctly |
| Media type versioning | Accept: application/vnd.acme.v2+json |
Strict content negotiation, multiple representations | Complex tooling; clients must set headers correctly |
| Query param versioning | ?version=2 |
Temporary migrations, experimentation | Easy to misuse; can leak into caches and bookmarks |
Think of your API as a “compiled interface” for teams. Once a client compiles against your contract, you can’t change the meaning under their feet. API versioning gives you a new contract while keeping the old one alive long enough to migrate.
Core concepts
1) The contract is bigger than JSON fields
Your API contract includes anything a client can reasonably depend on: endpoint paths, HTTP methods, status codes, required fields, validation rules, pagination semantics, sort order guarantees, error formats, and even rate limits. When you change any of these in an incompatible way, you’ve made a breaking change (even if the JSON “looks similar”).
Backward-compatible (usually safe)
- Adding optional fields to responses
- Adding new endpoints/resources
- Adding new query params with defaults
- Expanding capacity/limits (more lenient)
Breaking (clients can fail)
- Removing or renaming fields/endpoints
- Changing field types or required/optional status
- Changing meanings (same field, new semantics)
- Changing error codes or pagination behavior
2) Compatibility is about client behavior
A change is “breaking” if it causes a correct client to behave incorrectly: crash, mis-handle errors, silently corrupt data, or perform the wrong action. This is why “we never removed the field, we just changed what it means” is one of the most dangerous moves you can make.
If a client crashes, you notice quickly. If a client silently does the wrong thing, you discover it after a support ticket, a refund, or a data audit. When in doubt, treat meaning changes as a new version.
3) Where to put the version: pick the one you can operationalize
People argue about “the best” versioning style. In reality, the best style is the one your tooling, docs, and gateways can enforce consistently. Your choice should optimize for: discoverability, cache correctness, testability, and migration ergonomics.
Decision heuristics
- Public APIs with many clients: path versioning is explicit and easy to document.
- Internal APIs behind a gateway: header versioning supports gradual migration and per-client routing.
- Multiple representations: media types can be clean, but ensure your ecosystem supports it.
- Try to avoid: mixing multiple versioning methods within the same API surface.
4) Deprecation & sunset: the “adult” part of versioning
Versioning without a removal plan creates a museum: dozens of old versions you can’t shut off because nobody knows who’s still using them. A healthy API has a lifecycle: introduce → measure adoption → deprecate → sunset → remove.
A minimal deprecation policy
- Every version has an owner and a support window.
- Deprecated versions return a warning signal (headers, docs, dashboard).
- Sunset dates are explicit, communicated, and enforced.
- Removals are done after adoption reaches a threshold (or after the window ends).
5) Consumer-driven contracts: how to avoid surprise breakage
The server team rarely knows every way clients use the API. That’s why compatibility checks work best when they’re driven by clients: contract tests, schema validation, and “what do real clients send/expect?” telemetry.
- Version usage by client: Who is still calling v1? From where?
- Schema violations: Are clients sending unexpected fields/types? Are they failing on new fields?
Step-by-step
This is a pragmatic workflow you can reuse for every API change. It keeps the focus on compatibility first, version numbers second. You can run it with REST, GraphQL, or RPC-style APIs (the principles are the same: preserve contracts and migrate intentionally).
Step 1 — Classify the change: additive, breaking, or semantic?
- Additive: new optional fields, new endpoints, new features with defaults → usually no version bump.
- Breaking: removes/renames/changes required fields, types, behavior → requires a new API version (or parallel endpoint).
- Semantic: same shape, new meaning → treat as breaking unless you can prove clients won’t misbehave.
Step 2 — Design for compatibility (prefer widening over mutating)
When you want to change a response, the safest move is to add a new field and leave the old one intact until clients migrate. When you want to change behavior, create a new endpoint, a new resource, or a new representation rather than changing semantics in place.
Patterns that scale
- New field + deprecate old: keep both during migration
- New endpoint: e.g.,
/v2/ordersrather than changing/v1/orders - New representation: different response shape negotiated by headers
Patterns that cause regret
- Changing field meaning while keeping the name
- “Small cleanup” PRs that remove “unused” fields
- Expanding enums without client resilience (no unknown handling)
Step 3 — Choose your versioning mechanism (once)
Pick a mechanism that matches your ecosystem and commit to it. Below is a concrete example of media type versioning and path versioning side-by-side. Even if you don’t use these exact headers, the idea matters: make the version selection deterministic and visible.
# Option A: Path versioning (explicit URLs)
curl -sS https://api.example.com/v1/users?limit=10
# Option B: Media type versioning (content negotiation)
curl -sS https://api.example.com/users?limit=10 \
-H "Accept: application/vnd.example.users.v2+json"
# Option C: Header versioning (often easiest behind a gateway)
curl -sS https://api.example.com/users?limit=10 \
-H "X-API-Version: 2"
If you version via headers, make sure any caching layers vary correctly (e.g., by Accept or your version header).
If you version via path, the URL naturally separates cache keys (simpler operationally).
Step 4 — Implement parallel versions (don’t fork the whole world)
Supporting two versions doesn’t mean duplicating your entire backend. A clean implementation shares domain logic and splits only the contract layer: request parsing, response shaping, and validation. Think “two adapters, one core.”
import express from "express";
const app = express();
// Minimal version negotiation: default to v1, allow explicit v2
function getRequestedVersion(req) {
const hdr = String(req.header("X-API-Version") || "").trim();
if (hdr === "2") return 2;
return 1;
}
function addDeprecationSignals(res, { deprecated, sunsetISO }) {
if (!deprecated) return;
// These are widely recognized conventions; use what fits your org.
res.setHeader("Deprecation", "true");
res.setHeader("Sunset", sunsetISO); // e.g., "2026-07-01T00:00:00Z"
res.setHeader("Link", '<https://docs.example.com/migrate/v2>; rel="deprecation"');
}
app.get("/users", (req, res) => {
const v = getRequestedVersion(req);
if (v === 1) {
addDeprecationSignals(res, { deprecated: true, sunsetISO: "2026-07-01T00:00:00Z" });
// v1 shape (legacy)
return res.json({ users: [{ id: "u_1", name: "Ada" }] });
}
// v2 shape (new contract)
return res.json({
data: [{ id: "u_1", displayName: "Ada" }],
meta: { count: 1 }
});
});
app.listen(3000, () => console.log("API listening on :3000"));
Parallel-version checklist
- Share business logic; separate only validation + serialization
- Keep one source of truth for auth, rate limiting, and auditing
- Log version usage (version, client ID, route, status code)
- Build a migration guide before you announce the new version
Step 5 — Version documentation and schemas
Your docs are part of the contract. If you ship v2 but keep a single doc page that “kinda” describes both, you’ll get inconsistent integrations and support load. Prefer separate, explicit docs per version (or a clearly switchable doc view).
openapi: 3.0.3
info:
title: Example API
version: "2.0"
paths:
/v1/users:
get:
deprecated: true
summary: List users (v1 - deprecated)
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
users:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
/v2/users:
get:
summary: List users (v2)
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id: { type: string }
displayName: { type: string }
meta:
type: object
properties:
count: { type: integer }
Step 6 — Deprecate, measure, and sunset
Deprecation that works looks boring: a clear date, clear messaging, and a dashboard that shows adoption moving in the right direction. If you can’t measure who is still on v1, you can’t safely remove it.
Deprecation rollout
- Announce v2 with a migration guide (examples + mapping table)
- Mark v1 as deprecated (docs + headers + changelog)
- Notify lagging clients directly (email/slack/portal)
- Optionally throttle or warn more loudly as sunset approaches
Remove with confidence
- Ensure v1 traffic is near-zero for a sustained period
- Keep a rollback plan (feature flag or gateway route)
- Return a helpful error after sunset (not a vague 404)
- Archive docs and changelogs for compliance/history
Treat meaning changes as breaking changes. If the semantics change, ship a new field/endpoint/version. Your future self (and your clients) will thank you.
Common mistakes
Most API breakages are predictable. Here are the patterns that create the most pain, plus the practical fix you can apply immediately.
Mistake 1 — “We changed nothing important” (but clients disagree)
A tiny change in error codes, pagination, or field meaning can be catastrophic for automation.
- Fix: define the contract explicitly (status codes, error body, pagination rules).
- Fix: add contract tests and treat changes as API changes, not implementation details.
Mistake 2 — Removing fields because “nobody uses them”
You can’t know that without usage telemetry, and even then you need a migration window.
- Fix: deprecate first, measure usage, then remove after the sunset window.
- Fix: keep responses forward-compatible: clients should ignore unknown fields.
Mistake 3 — Mixing versioning styles across endpoints
Some endpoints are /v1, others use headers, others have ?v=2. Everyone loses.
- Fix: choose one mechanism and document it as a platform standard.
- Fix: enforce it via lint rules, API gateway policies, or PR checklists.
Mistake 4 — “v2” duplicates everything (and diverges fast)
Forking codebases makes every bug fix twice as expensive and pushes you to keep v1 forever.
- Fix: share core domain logic; split only the contract/serialization layer.
- Fix: reduce surface area: fewer endpoints, clearer resource models.
Mistake 5 — Expanding enums without planning for “unknown”
Old clients may crash or mis-handle new enum values.
- Fix: document that clients must handle unknown enum values (treat as “other”).
- Fix: consider string enums with a safe fallback rather than hard-coded switches.
Mistake 6 — No measurable deprecation
You announce a sunset date, but you don’t know who is still using the old version.
- Fix: log version usage by client; build a simple dashboard.
- Fix: add deprecation signals (headers, docs badges, SDK warnings).
“No one will notice.” If you can’t prove it with tests and telemetry, assume someone will notice—at 2 a.m., during an incident.
FAQ
Do I always need API versioning?
No. Most API evolution can be done without a new version by making additive changes (new optional fields, new endpoints, new capabilities). You need API versioning when you must change the contract in a way that would break existing clients (types, required fields, semantics, error behavior).
Is URL versioning (/v1) bad practice?
No. It’s often the most practical option for public REST APIs because it’s explicit, easy to document, and easy to test. The “bad practice” is not the location of the version—it’s shipping breaking changes without a migration plan, or accumulating versions with no sunset policy.
What counts as a breaking change in a JSON API?
Removing/renaming fields or endpoints, changing a field type, making an optional field required, changing validation rules in a way that rejects formerly valid requests, changing status codes/error formats, and semantic changes (same field name, different meaning) are all breaking. Adding optional fields is usually safe.
How long should I support an old API version?
Support windows depend on your client ecosystem. Public APIs and enterprise customers often need longer windows. A common approach is a fixed window (e.g., 6–18 months) plus a measurable threshold (e.g., “remove when <1% traffic for 30 days”). What matters most: it’s explicit, communicated, and backed by telemetry.
How do I version GraphQL or gRPC?
GraphQL typically versions through deprecation at the field level and additive schema changes, not “v1/v2 endpoints.” gRPC with protobuf is designed for compatibility if you follow protobuf evolution rules (don’t reuse field numbers, don’t change types, prefer adding new fields). The principle is the same: avoid breaking changes; use deprecation and parallel fields when needed.
How do I prevent accidental breaking changes?
Make compatibility a gate. Use API review checklists, contract tests, and schema validation in CI. Track version usage and test representative clients (or generated SDKs) against staging before you ship changes to production.
Cheatsheet
A scan-fast checklist you can keep next to your PR template.
API versioning without regret: the checklist
- Contract: define what clients can rely on (fields, errors, pagination, semantics)
- Default strategy: prefer additive changes; avoid meaning changes
- Breaking change? ship a parallel contract (new version or new endpoint)
- Pick one mechanism: path OR headers OR media types (don’t mix)
- Parallel versions: share core logic; split only adapters/serializers
- Docs: version docs/specs/SDKs; don’t “blend” versions on one page
- Deprecation: signal (headers + docs), measure adoption, set a sunset date
- Telemetry: track version usage by client and endpoint
- Removal: enforce sunset with helpful errors and a rollback plan
| If you want to… | Prefer… | Instead of… |
|---|---|---|
| Add new capability | Add optional fields / new endpoint | Changing existing field meaning |
| Change response structure | Introduce v2 shape in parallel | Mutating v1 shape in place |
| Retire legacy behavior | Deprecate + measure + sunset | Deleting endpoints “quietly” |
| Reduce maintenance cost | Share core logic across versions | Forking entire services per version |
Encourage (and test) clients to be forward-compatible: ignore unknown fields, handle unknown enum values, and rely on documented semantics—not incidental behavior.
Wrap-up
“API versioning without regret” is mostly about not needing versions too often. If you treat your API as a contract, prefer additive evolution, and run deprecation as a measured rollout, you can change your system without waking up to broken clients.
What to do next
- Pick a single versioning mechanism for your org and document it as the standard
- Add a lightweight compatibility checklist to every API PR
- Instrument version usage by client so deprecation becomes safe and boring
- Write one migration guide template (before you need it in a rush)
Want to go deeper? The related posts below connect the dots: consistent API design rules, clean architecture boundaries, and resilience patterns that make migrations safer.
Quiz
Quick self-check: versioning, compatibility, and deprecation basics.