You shipped v1 of your API. Users integrated it. Now you need breaking changes. How do you evolve without breaking everyone?

API versioning seems simple until you actually do it. Here’s what works, what doesn’t, and how to pick the right strategy.

The Core Problem

APIs are contracts. When you change the response format, rename fields, or alter behavior, you break that contract. Clients built against v1 stop working when you ship v2.

The goal: evolve your API while giving clients time to migrate.

Versioning Strategies

URL Path Versioning

GGEETT//aappii//v12//uusseerrss

Pros:

  • Obvious and explicit
  • Easy to route in any framework
  • Easy to deprecate (just shut down the old path)
  • Cacheable — different URLs, different cache entries

Cons:

  • Pollutes your URL space
  • Can lead to massive code duplication

When to use: Public APIs, when you expect major version bumps, when simplicity matters more than purity.

Header Versioning

GAEcTce/patp:i/aupspelriscation/vnd.myapi.v2+json

Or custom header:

GXE-TAP/Ia-pVie/russieorns:2

Pros:

  • Clean URLs
  • RESTful purists approve
  • Can version at granular level

Cons:

  • Easy to forget the header
  • Harder to test in browser
  • Some proxies strip custom headers

When to use: Internal APIs, when URL aesthetics matter, when you have sophisticated clients.

Query Parameter Versioning

GET/api/users?version=2

Pros:

  • Easy to add to any request
  • Works in browser
  • Optional — can default to latest

Cons:

  • Feels hacky
  • Query params should filter data, not change structure
  • Caching can be tricky

When to use: Honestly, rarely. It works, but other approaches are cleaner.

The Practical Approach: URL + Additive Changes

Most teams land here:

  1. Major versions in URL (/v1/, /v2/)
  2. Minor changes are additive (new fields, new endpoints)
  3. Never remove or rename fields within a version
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# v1 response
{
  "id": 123,
  "name": "Alice"
}

# v1 response after "minor" update (still v1!)
{
  "id": 123,
  "name": "Alice",
  "email": "alice@example.com",  # Added, not breaking
  "created_at": "2026-01-15"     # Added, not breaking
}

Clients that don’t expect email will ignore it. No breakage.

What Counts as Breaking?

Breaking changes (require new version):

  • Removing a field
  • Renaming a field
  • Changing a field’s type ("count": "5""count": 5)
  • Changing required/optional status
  • Changing error response format
  • Changing authentication method
  • Removing an endpoint

Non-breaking changes (safe within version):

  • Adding new fields to responses
  • Adding new optional query parameters
  • Adding new endpoints
  • Adding new enum values (usually)
  • Performance improvements

Implementation Patterns

Versioned Controllers

Keep versions completely separate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# app/api/v1/users.py
@router.get("/users/{id}")
def get_user_v1(id: int):
    user = db.get(id)
    return {"id": user.id, "name": user.name}

# app/api/v2/users.py  
@router.get("/users/{id}")
def get_user_v2(id: int):
    user = db.get(id)
    return {
        "id": user.id,
        "name": user.name,
        "email": user.email,
        "metadata": user.metadata
    }

Simple but leads to duplication. Use when versions are substantially different.

Shared Logic, Version-Specific Serialization

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
def get_user(id: int):
    return db.get(id)  # Same logic

def serialize_user_v1(user):
    return {"id": user.id, "name": user.name}

def serialize_user_v2(user):
    return {
        "id": user.id,
        "name": user.name,
        "email": user.email,
        "metadata": user.metadata
    }

@router.get("/v1/users/{id}")
def get_user_v1(id: int):
    return serialize_user_v1(get_user(id))

@router.get("/v2/users/{id}")
def get_user_v2(id: int):
    return serialize_user_v2(get_user(id))

Business logic stays DRY, only serialization differs.

Feature Flags Over Versions

For internal APIs, consider feature flags instead:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@router.get("/users/{id}")
def get_user(id: int, request: Request):
    user = db.get(id)
    
    response = {"id": user.id, "name": user.name}
    
    if feature_enabled("user_metadata", request.client_id):
        response["metadata"] = user.metadata
    
    return response

Roll out changes gradually, per-client. No hard version cutover.

Deprecation Done Right

Don’t just kill old versions. Give clients a migration path:

1. Announce Early

DLeipnrke:ca<thitotnp-sN:o/t/idcoec:s.ve1xasmupnlsee.tcoomn/m2i0g2r6a-t0i6o-n0>1;rel="deprecation"

Add headers to every v1 response, months before shutdown.

2. Provide Migration Guides

Document every change:

1
2
3
4
5
6
7
8
9
## Migrating from v1 to v2

### User endpoint changes

| v1 field | v2 field | Notes |
|----------|----------|-------|
| `name` | `full_name` | Renamed for clarity |
| - | `email` | New required field |
| `created` | `created_at` | Now ISO 8601 format |

3. Sunset Gradually

WWWWWeeeeeeeeeekkkkk14811:::26::DWvea1vvpr11rnreierrcntaeagutttreuienromslnnaisi2mhl9i4es9t1ae0dtWdeoaGrroshnneieaandvgdyehdve1aduesrers

4. Monitor Usage

Track which clients still use deprecated endpoints:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@middleware
def track_version_usage(request, call_next):
    version = extract_version(request.url)
    client_id = request.headers.get("X-Client-ID", "unknown")
    
    metrics.increment(
        "api.request",
        tags={"version": version, "client": client_id}
    )
    
    return call_next(request)

What About GraphQL?

GraphQL handles this differently — no explicit versions. Instead:

  • Add new fields anytime
  • Mark old fields @deprecated
  • Eventually remove (after checking usage)
1
2
3
4
5
6
type User {
  id: ID!
  name: String! @deprecated(reason: "Use fullName instead")
  fullName: String!
  email: String!
}

Clients see deprecation warnings in tooling. Works well for sophisticated clients, harder for simple integrations.

Common Mistakes

1. Too Many Versions

Don’t version every change. If you’re at v17, something went wrong. Aim for major versions lasting 1-2 years.

2. No Default Version

Always specify what happens with no version:

1
2
3
4
# Explicit default
@router.get("/users/{id}")  # Defaults to v2
def get_user_default(id: int):
    return get_user_v2(id)

Or return an error requiring explicit version — stricter but clearer.

3. Breaking Changes in Minor Updates

If clients expect stability within a version, deliver it. “We added a field that might be null” can still break parsers.

4. Forgetting Documentation

Your API docs need version selectors. Every example needs version context. Nothing worse than docs that don’t match the version you’re using.

The Bottom Line

Start simple:

  1. URL path versioning (/v1/, /v2/)
  2. Additive changes within versions
  3. Clear deprecation timeline
  4. Migration documentation

You can get fancy later. Most APIs never need more than this.


Your API is a promise. Version carefully, deprecate gracefully, and your clients will thank you.