API Design & Development

API Versioning and Backward Compatibility: Evolving APIs Safely

MatterAI
MatterAI
12 min read·

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:

  1. Announce: document the deprecation and the target date.
  2. Overlap: run old and new versions in parallel for a defined window (typically 6-12 months).
  3. Measure: track usage of the deprecated version. Deprecate only when usage is near zero.
  4. 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 @deprecated directive:
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

Get started free - https://app.matterai.so


Follow us on X · LinkedIn · GitHub

Share this Guide:

Ship Faster. Ship Safer.

Join thousands of engineering teams using MatterAI to autonomously build, review, and deploy code with enterprise-grade precision.

No credit card requiredSOC 2 Type IISetup in 2 min