API Versioning and Backward Compatibility: Evolving APIs Safely
API Versioning and Backward Compatibility: Evolving APIs Safely
Every API eventually needs to change, and every change risks breaking a client you do not control. The discipline is simple in principle: never break existing clients, and give them a clear path forward. This guide covers versioning strategies, the rules of backward compatibility, and the deprecation lifecycle.
Versioning Strategies
URI Versioning
The most common and most visible approach:
GET /v1/orders/123
GET /v2/orders/123
// Express — URI versioning
app.use("/v1", v1Router);
app.use("/v2", v2Router);
Pros: explicit, cacheable, easy to debug. Cons: URL churn, and it encourages forking entire endpoints instead of evolving them.
Header Versioning
The version lives in a header:
GET /orders/123
Accept: application/vnd.acme.orders.v2+json
// Express — header versioning
app.get("/orders/:id", (req, res) => {
const version = req.headers["accept"]?.match(/v(\d+)/)?.[1] ?? "1";
if (version === "2") return handleV2(req, res);
return handleV1(req, res);
});
Pros: clean URLs, fine-grained control. Cons: invisible in logs and browser devtools, harder to cache, clients must remember to send it.
Query Parameter Versioning
GET /orders/123?version=2
Pros: trivial to implement. Cons: pollutes the URL, easy to forget, and it is the weakest of the three. Fine for internal APIs, poor for public ones.
The Compatibility Rules
Versioning is a fallback. The first line of defense is backward-compatible evolution:
Additive changes are always safe:
- Adding a new optional field to a response
- Adding a new endpoint
- Adding a new enum value (if clients handle unknown values)
- Adding a new optional request parameter
Breaking changes require a new version:
- Removing or renaming a field
- Changing a field's type
- Changing an enum's meaning
- Making an optional parameter required
- Changing error semantics
- Removing an endpoint
// Safe: add an optional field
type OrderV1 = {
id: string;
total: number;
};
// Safe: additive evolution
type OrderV2 = OrderV1 & {
currency: string; // new optional field
};
The Deprecation Lifecycle
A version does not die on a date; it dies on a process. The lifecycle:
- Announce: document the deprecation and the target date.
- Overlap: run old and new versions in parallel for a defined window (typically 6-12 months).
- Measure: track usage of the deprecated version. Deprecate only when usage is near zero.
- Remove: delete the old version, keep the docs.
// Track deprecated version usage
app.use("/v1", (req, res, next) => {
metrics.increment("api.v1.requests", { path: req.path });
res.setHeader("Deprecation", "true");
res.setHeader("Sunset", "Wed, 01 Jan 2027 00:00:00 GMT");
next();
});
The Deprecation and Sunset headers are the standard way to signal deprecation to automated clients.
Compatibility Testing
Backward compatibility is a property you can test. Contract testing (Pact) and schema diffing catch breaking changes in CI:
// Schema diffing with zod — fail CI on breaking changes
import { diff } from "json-schema-diff";
const breaking = diff(v1OrderSchema, v2OrderSchema).filter(
(change) => change.type === "removed" || change.type === "type-changed",
);
if (breaking.length > 0) {
throw new Error(`Breaking change detected: ${JSON.stringify(breaking)}`);
}
# CI job — run contract tests against the published provider
- name: Verify provider contracts
run: npx pact-broker can-i-deploy --pacticipant orders-api --version $GITHUB_SHA
GraphQL: Versioning Is Different
GraphQL has no versions by design: the schema is the contract, and evolution is additive. The rules:
- Add fields, never remove them (until you run a schema migration).
- Deprecate with the
@deprecateddirective:
type Order {
id: ID!
total: Float!
currency: String @deprecated(reason: "Use totalCurrency instead")
totalCurrency: String!
}
- Use schema registry checks to block breaking changes in CI (Apollo Studio, GraphQL Inspector).
# Block breaking schema changes in CI
npx graphql-inspector diff schema.graphql --rule 'no-breaking-changes'
Practical Rules for API Teams
- Default to additive changes. Most "breaking" changes can be done additively with a deprecation window.
- Never silently change semantics. Changing what a field means without changing its shape is the worst kind of break: it fails at runtime, not at compile time.
- Document every version. A version without docs is a trap.
- Version the errors too. Error shapes change; version them with the API.
- Keep old versions alive long enough. The cost of running an old version is small; the cost of breaking a client is reputation.
Implementation Checklist
- Choose a versioning strategy (URI, header, or query)
- Default to additive, backward-compatible changes
- Define the deprecation lifecycle with dates
- Send Deprecation and Sunset headers
- Add schema diffing to CI
- Add contract tests for consumers
- Track deprecated version usage
- Document every version and its changes
MatterAI builds frontier AI infrastructure for engineering teams — from inference-optimized models to autonomous coding agents and agentic code reviews.
Explore what we're building:
- Orbital IDE — Autonomous AI coding agent with background agents and deep codebase memory
- AI Code Reviews — Agentic pre-commit reviews across GitHub, GitLab, and Bitbucket
- Axon Models — Frontier-grade reasoning models at 70% lower inference cost
Share this Guide:
More Guides
Model Context Protocol (MCP): Building MCP Servers from Scratch
Build production-grade MCP servers with the TypeScript and Python SDKs. Covers the MCP architecture, stdio and HTTP transports, tools, resources, prompts, and the security model every AI application needs.
16 min readRAG vs Fine-Tuning: When to Use Each for Your LLM Application
Decide between retrieval-augmented generation and fine-tuning with a practical decision framework. Compare cost, latency, freshness, and accuracy, and see working implementations of both approaches.
13 min readGrafana Stack vs SigNoz: Choosing an OpenTelemetry-Native Observability Platform
Compare the Grafana LGTM stack (Loki, Tempo, Mimir) with SigNoz for OpenTelemetry observability: architecture, storage engines, operational overhead, cost at scale, and migration paths.
10 min readLLM Fine-Tuning: LoRA vs QLoRA vs Full Fine-Tuning
Compare full fine-tuning, LoRA, and QLoRA for LLMs with memory requirements, training recipes, and code. Learn which method fits your GPU budget, dataset size, and quality requirements.
14 min readPrompt Injection Defense: Securing LLM Apps Against Jailbreaks, RAG Poisoning, and Tool-Use Exploits
Defend production LLM applications against direct jailbreaks, indirect injection via RAG pipelines, and tool-use exploits with layered defenses, input filtering, and least-privilege tool design.
10 min readContinue Reading
Model Context Protocol (MCP): Building MCP Servers from Scratch
Build production-grade MCP servers with the TypeScript and Python SDKs. Covers the MCP architecture, stdio and HTTP transports, tools, resources, prompts, and the security model every AI application needs.
16 min readRAG vs Fine-Tuning: When to Use Each for Your LLM Application
Decide between retrieval-augmented generation and fine-tuning with a practical decision framework. Compare cost, latency, freshness, and accuracy, and see working implementations of both approaches.
13 min readGrafana Stack vs SigNoz: Choosing an OpenTelemetry-Native Observability Platform
Compare the Grafana LGTM stack (Loki, Tempo, Mimir) with SigNoz for OpenTelemetry observability: architecture, storage engines, operational overhead, cost at scale, and migration paths.
10 min readShip Faster. Ship Safer.
Join thousands of engineering teams using MatterAI to autonomously build, review, and deploy code with enterprise-grade precision.
