API versioning is the practice of labeling and managing changes to an API’s contract so existing clients keep working while new capabilities ship. Adjacent concepts include backward compatibility, deprecation policy, semantic versioning, and change management. For teams with 20–200 developers, versioning is not a ceremony: it is the difference between a platform that evolves and one that quietly breaks integrations every quarter. Most strategies I see are backwards because they optimize for the producer’s convenience, not the consumer’s reality.

Here is the uncomfortable truth: if your versioning strategy starts with “we’ll just put /v2 in the URL,” you have already skipped the hard part. The hard part is deciding what a breaking change actually is, who gets to make one, and how long the old contract lives. This article is a practical, no-nonsense look at why common versioning advice fails, what to do instead, and how to make a decision you can defend in an incident review.

Developer reviewing API documentation on a laptop screen

The Backwards Default: Versioning as a Routing Hack

Most teams treat versioning as a routing problem. Add /v1 or /v2 to the path, duplicate the controller, and move on. This feels productive because it is visible. The URL changes, the OpenAPI spec gets a new entry, and the release notes say “v2 released.”

The problem is that path-based versioning encodes the wrong thing. It says: “This is a different resource,” when what you usually mean is: “This is the same resource with a different shape.” Clients now have to know which URL to call, and your routing table becomes a museum of past mistakes. Worse, path versioning encourages teams to create a new version for changes that are not breaking at all, because it is easier than doing the compatibility work.

What “Backwards” Looks Like in Practice

Here is a before-and-after example from a typical REST API. The team wants to rename user_name to display_name.

Backwards approach:

GET /api/v1/users/42
{
  "user_name": "priya"
}

GET /api/v2/users/42
{
  "display_name": "priya"
}

Better approach:

GET /api/users/42
{
  "user_name": "priya",
  "display_name": "priya"
}

The better approach adds the new field, keeps the old one, and documents a deprecation date. No new URL, no new client work, no routing table explosion. The backwards approach creates a parallel universe for a rename that could have been a non-breaking addition.

The Real Unit of Versioning Is the Contract, Not the URL

A version is a promise about behavior. The promise includes request and response shapes, error semantics, rate limits, and side effects. When you change any of those in a way that breaks a reasonable client, you have a new version. The URL is just one place to express that version.

This is why I prefer content negotiation or header-based versioning for most internal and partner APIs. The resource stays stable; the representation changes. It forces you to think about what the client actually accepts, not what path they memorized.

Decision Matrix: When to Use Which Versioning Style

Situation Recommended style Why
Public API with many unknown clients Path or query parameter Visible, cacheable, easy to document
Internal API with 2–5 known consumers Header or content negotiation Less URL churn, easier to evolve
Webhooks sent to external endpoints Event schema version in payload Consumer cannot change headers easily
SDK generation from OpenAPI Path or header, but stable operation IDs Code generators need predictable names

None of these are universally correct. The point is to choose based on who consumes the API and how they discover changes, not based on what your framework makes easiest.

Close-up of code on a monitor showing API endpoints

Deprecation Is the Strategy; Versioning Is the Tactic

Most teams treat deprecation as an afterthought. They ship v2, add a note that v1 “will be removed soon,” and then never remove it. Three years later, v1 is still running, v2 has its own quirks, and nobody knows which one is canonical.

A sane deprecation policy has three parts:

  • Announcement: a dated, public notice that a field, endpoint, or behavior is deprecated.
  • Sunset window: a fixed period, usually 6–12 months for public APIs, during which the old contract still works.
  • Removal: a hard cutoff with monitoring to catch stragglers.

If you cannot commit to all three, do not deprecate. Just keep the old field forever and document it as legacy. A permanent legacy field is less harmful than a “deprecated” field that never dies, because at least the status is honest.

Checklist: Is This Change Breaking?

Use this checklist before you reach for a new version number:

  • Are you removing a field that clients might read? Breaking.
  • Are you changing a field’s type or format? Breaking.
  • Are you adding a required request field? Breaking.
  • Are you changing an error code or status code for the same situation? Breaking.
  • Are you adding a new optional field? Not breaking.
  • Are you adding a new endpoint? Not breaking.
  • Are you changing rate limits? Usually breaking for clients that relied on the old limit.

If you answer “breaking” to any of these, you have a versioning decision. If you answer “not breaking” to all of them, you have a normal release. The backwards strategy is to treat every change as a version bump because it is easier than doing the analysis.

Webhooks Make Versioning Visible

Webhooks are where versioning mistakes become customer-facing incidents. A webhook payload is a contract you push to someone else’s server. They cannot change their parser as fast as you can change your emitter.

The practical rule: include a version field in every webhook payload, and never change the meaning of an existing version. If you need to change the payload shape, emit a new version and let consumers opt in. This is not theoretical. Stripe, for example, uses event schema versions and API versions together, and their documentation is explicit about how long old versions remain supported.

For internal webhooks, the same rule applies. The team that owns the consumer may be two desks away, but they still need a migration window. A webhook without a version field is a future incident with a timestamp.

SDK Generation Changes the Math

If you generate SDKs from an OpenAPI spec, versioning decisions leak into code generation. A path-based version like /v2 often becomes a separate client class or namespace. That is fine if the change is truly breaking. But if you version for a non-breaking addition, you now have two SDKs for one API, and users have to know which one to import.

The better pattern is to keep operation IDs stable and use the spec’s deprecated flag on fields and operations. Code generators can then produce deprecation warnings in the SDK, which is a much gentler migration path than a new major version.

Before/After: OpenAPI Deprecation

Before:

paths:
  /users/{id}:
    get:
      operationId: getUser
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string

After:

paths:
  /users/{id}:
    get:
      operationId: getUser
      deprecated: false
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: include_legacy_fields
          in: query
          required: false
          schema:
            type: boolean
            default: false
          deprecated: true

The after version keeps the operation ID stable, adds a deprecated query parameter for legacy clients, and gives SDK users a compiler warning instead of a breaking import change.

Two engineers discussing API versioning at a whiteboard

Why “Just Use GraphQL” Is Not a Versioning Strategy

GraphQL advocates sometimes claim that GraphQL solves versioning because clients request exactly the fields they need. That is true for additive changes. It is not true for removing a field, changing a field’s type, or changing the semantics of a resolver. Those are still breaking changes, and GraphQL’s answer is usually “don’t do that” or “use a new field name.”

For REST APIs, the same discipline applies: add new fields, never remove old ones without a deprecation window, and treat semantic changes as breaking. The difference is that REST makes the contract explicit in the URL and status codes, while GraphQL hides it in the schema. Neither approach saves you from thinking about compatibility.

A One-Week Action Plan

Here is something you can do this week, not next quarter:

  1. Pick one public or internal API your team owns.
  2. List every field, endpoint, and error code that has changed in the last 12 months.
  3. Mark each change as breaking or non-breaking using the checklist above.
  4. For each breaking change, write down what the versioning strategy actually was. If the answer is “we added /v2 and hoped,” you have found the problem.
  5. Draft a one-page deprecation policy: announcement channel, sunset window, removal criteria.
  6. Share it with the team that consumes the API. Ask them what would make migration easier.

That is six steps. None of them require a new framework or a re-architecture. They require you to look at the contract from the consumer’s side, which is the only side that matters.

FAQ

What is the most common API versioning mistake?

The most common mistake is using a new URL version for changes that are not breaking. This creates parallel APIs, confuses clients, and makes deprecation harder. The fix is to classify changes as breaking or non-breaking before deciding on a version.

Should I use URL, header, or query parameter versioning?

It depends on your consumers. For public APIs with unknown clients, URL or query parameter versioning is more visible and cacheable. For internal APIs with a few known consumers, header-based versioning or content negotiation reduces URL churn. For webhooks, put the version in the payload itself.

How long should I support an old API version?

A common sunset window is 6–12 months for public APIs, but the right answer depends on your consumers’ release cycles. The key is to announce the deprecation with a specific date, monitor usage, and actually remove the old version when the window closes. A permanent legacy field is better than a “deprecated” field that never dies.

Does semantic versioning apply to REST APIs?

Semantic versioning is useful for libraries and SDKs, but it maps awkwardly to REST APIs because the “major version” is often expressed in the URL or header. The principles still apply: breaking changes require a new major version, additive changes do not. The mistake is treating every release as a major version bump.

Next up on the blog: a practical guide to writing deprecation notices that engineers actually read. If you have a versioning horror story, send it in — the best one gets a dry, sympathetic response.