DEV Community

Tech Forge
Tech Forge

Posted on

API versioning that ages well

Start with a contract, not a version number

Most API versioning debates focus on the URL (/v1, /v2) or the header (Accept: application/vnd.api+json;version=2). Those are delivery mechanisms. The real question is: how do you change behavior without breaking consumers?

I've found that the version that ages best is the one that treats the API as a contract, not a file to edit. You don't version the code; you version the behavior.

Prefer additive changes over version bumps

Before you create a new version, ask: can I add a field, an endpoint, or a parameter without breaking existing clients? Most of the time, yes.

// old response
{
  "user": {
    "id": 1,
    "name": "Alice"
  }
}

// new response, still backward compatible
{
  "user": {
    "id": 1,
    "name": "Alice",
    "email": "alice@example.com"  // added field
  }
}
Enter fullscreen mode Exit fullscreen mode

Adding a field is safe. Changing a field type, removing a field, or renaming a field is breaking. So make additive changes the default, and reserve version bumps for genuinely breaking changes.

Use semantic versioning for your API

Just like libraries, your API can follow SemVer: MAJOR for breaking changes, MINOR for backward-compatible additions, PATCH for fixes. This gives consumers a mental model: they can safely upgrade within the same MAJOR version.

But SemVer alone doesn't solve the deployment problem. You still need to support old clients while you roll out new ones.

Support multiple versions simultaneously

Instead of forcing everyone to upgrade at once, run several versions in parallel. This is where the URL or header choice matters, but the key is to keep the versioned code thin.

# Flask example
from flask import Flask, request

app = Flask(__name__)

@app.route('/api/users/<int:user_id>')
def get_user(user_id):
    version = request.headers.get('Accept-Version', '1')
    if version == '2':
        return get_user_v2(user_id)
    return get_user_v1(user_id)
Enter fullscreen mode Exit fullscreen mode

Keep the version-specific logic in separate functions or modules. Don't scatter if version == checks all over your codebase. That becomes a maintenance nightmare.

Deprecate with a timeline, not a cliff

When you introduce a new version, set a deprecation date for the old one. Communicate that date in the response headers and in your docs. Give consumers at least 6-12 months, depending on your ecosystem.

HTTP/1.1 200 OK
Deprecation: true
Sunset: Wed, 31 Dec 2025 23:59:59 GMT
Enter fullscreen mode Exit fullscreen mode

The Deprecation and Sunset headers are standardized (see RFC 8594) and let clients programmatically know when to move.

Avoid versioning in the database schema

A common mistake is to version the database schema to match the API. That couples your storage to your public contract. Instead, keep the internal model stable and transform data at the API boundary.

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

def to_v2(user):
    return {"id": user.id, "name": user.name, "email": user.email}
Enter fullscreen mode Exit fullscreen mode

This way, you can add a new API version without a database migration.

Document the changes, not just the endpoints

A changelog is essential. For each version, list what changed, what was added, and what was deprecated. Include examples of before/after requests and responses. Your future self will thank you.

Know when to break things

Sometimes additive changes aren't enough. For example, if you need to fix a security vulnerability, or if the old behavior is fundamentally wrong, you might need a breaking change. That's fine, but do it deliberately: bump the MAJOR version, announce it clearly, and provide migration guides.

The version that ages well is the one you rarely use

If you're constantly creating new versions, you're probably not being additive enough. The best versioning strategy is the one where v1 lives for years because you designed it to be extensible from the start. Use optional fields, use enums with room to grow, and avoid tight coupling to internal implementation details.

Final thoughts

Versioning is a communication tool. It tells consumers when they can safely upgrade and when they need to pay attention. By being additive, using SemVer, supporting parallel versions, and deprecating with a timeline, you make your API easier to evolve and easier to consume. That's what ages well.

Remember: the goal is not to have many versions, but to have few, well-designed ones that can coexist peacefully.

Top comments (0)