Skip to content
API Versioning: Strategies and Best Practices

API Versioning: Strategies and Best Practices

DodaTech Updated Jun 20, 2026 11 min read

API versioning is the practice of managing changes to a public API over time by assigning version identifiers to different iterations of the API, allowing consumers to upgrade at their own pace without breaking existing integrations.

What You’ll Learn

By the end of this tutorial, you’ll understand all major API versioning strategies — URL, header, and query parameter — their trade-offs, how to handle breaking vs non-breaking changes, and how to design deprecation policies.

Why API Versioning Matters

Once an API is public, consumers depend on its behavior. Changing a response field name, removing an endpoint, or altering validation rules breaks every consumer. DodaTech’s Durga Antivirus Pro API serves 5,000+ partner integrations — breaking changes would cause widespread failures.

API Evolution


flowchart LR
    subgraph "API Lifecycle"
        V1["v1 (Current)"] --> D["Deprecation Notice"]
        D --> V1S["v1 Sunset"]
        V2["v2 (New)"] --> M["Migration Guide"]
        M --> C["Consumers migrate"]
        C --> V2S["v2 becomes current"]
    end

    subgraph "Version Strategy"
        S1["URL: /api/v1/users"]
        S2["Header: Accept: app.v2+json"]
        S3["Query: ?version=2"]
    end
    style V1 fill:#22c55e,color:#fff
    style V1S fill:#ef4444,color:#fff

Strategy 1: URL Versioning

# url_versioning.py
# API with URL-based versioning (/v1/, /v2/)

from flask import Flask, jsonify, request

app = Flask(__name__)

# In-memory data store
users = {
    1: {'name': 'Alice', 'email': 'alice@example.com'},
    2: {'name': 'Bob', 'email': 'bob@example.com'},
}

# ── Version 1: Simple user list ──

@app.route('/api/v1/users', methods=['GET'])
def v1_list_users():
    """v1: Returns user list with basic fields."""
    result = [
        {'id': uid, 'name': u['name']}
        for uid, u in users.items()
    ]
    return jsonify({'users': result, 'version': '1.0', 'count': len(result)})

@app.route('/api/v1/users/<int:user_id>', methods=['GET'])
def v1_get_user(user_id):
    """v1: Returns user with id and name."""
    user = users.get(user_id)
    if not user:
        return jsonify({'error': 'User not found'}), 404
    return jsonify({
        'id': user_id,
        'name': user['name'],
        'version': '1.0',
    })

# ── Version 2: Enhanced with email, created_at, pagination ──

from datetime import datetime, timedelta
import random

# Enhanced data for v2
user_details = {
    1: {
        'name': 'Alice', 'email': 'alice@example.com',
        'created_at': (datetime.now() - timedelta(days=365)).isoformat(),
        'role': 'admin', 'status': 'active',
    },
    2: {
        'name': 'Bob', 'email': 'bob@example.com',
        'created_at': (datetime.now() - timedelta(days=30)).isoformat(),
        'role': 'user', 'status': 'active',
    },
}

@app.route('/api/v2/users', methods=['GET'])
def v2_list_users():
    """v2: Paginated user list with full details."""
    page = request.args.get('page', 1, type=int)
    per_page = request.args.get('per_page', 10, type=int)

    all_users = list(user_details.values())
    total = len(all_users)
    start = (page - 1) * per_page
    end = start + per_page
    page_users = all_users[start:end]

    result = []
    for u in page_users:
        result.append({
            'name': u['name'],
            'email': u['email'],
            'role': u['role'],
            'created_at': u['created_at'],
            '_links': {
                'self': f'/api/v2/users/{users.index(u) + 1}',
            }
        })

    return jsonify({
        'data': result,
        'pagination': {
            'page': page,
            'per_page': per_page,
            'total': total,
            'total_pages': (total + per_page - 1) // per_page,
        },
        'version': '2.0',
    })

@app.route('/api/v2/users/<int:user_id>', methods=['GET'])
def v2_get_user(user_id):
    """v2: Returns user with all details."""
    user = user_details.get(user_id)
    if not user:
        return jsonify({'error': 'User not found', 'code': 'NOT_FOUND'}), 404

    return jsonify({
        'data': user,
        'version': '2.0',
    })

Expected output:

# v1 response
curl http://localhost:5000/api/v1/users
# {"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}], "version": "1.0", "count": 2}

# v2 response
curl http://localhost:5000/api/v2/users?page=1&per_page=10
# {"data": [{"name": "Alice", "email": "alice@example.com", ...}], "pagination": {...}, "version": "2.0"}

Strategy 2: Header Versioning

# header_versioning.py
# API versioning via Accept header (Content Negotiation)

from flask import Flask, jsonify, request

app = Flask(__name__)

def get_api_version():
    """Extract API version from Accept header.
    
    Expected format: Accept: application/vnd.dodatech.v1+json
    """
    accept = request.headers.get('Accept', '')

    # Parse version from vendor-specific media type
    # application/vnd.dodatech.v1+json → v1
    # application/vnd.dodatech.v2+json → v2
    import re
    match = re.search(r'vnd\.dodatech\.v(\d+)\+json', accept)
    if match:
        return int(match.group(1))

    # Default to latest version
    return 2

@app.route('/api/users', methods=['GET'])
def list_users():
    """Versioned endpoint using Accept header negotiation."""
    version = get_api_version()

    users_data = {
        1: {'name': 'Alice', 'email': 'alice@example.com'},
        2: {'name': 'Bob', 'email': 'bob@example.com'},
    }

    if version == 1:
        # v1: simple format
        result = [
            {'id': uid, 'name': u['name'], 'email': u['email']}
            for uid, u in users_data.items()
        ]
        return jsonify({'users': result, 'version': '1.0'})

    elif version == 2:
        # v2: enriched format with links
        result = [
            {
                'name': u['name'],
                'email': u['email'],
                '_links': {'self': f'/api/users/{uid}'},
            }
            for uid, u in users_data.items()
        ]
        return jsonify({'data': result, 'version': '2.0'})

    else:
        return jsonify({'error': f'Unsupported version: {version}'}), 400

Testing:

curl -H "Accept: application/vnd.dodatech.v1+json" http://localhost:5000/api/users
# {"users": [{"id": 1, "name": "Alice", "email": "alice@example.com"}, ...], "version": "1.0"}

curl -H "Accept: application/vnd.dodatech.v2+json" http://localhost:5000/api/users
# {"data": [{"name": "Alice", "email": "alice@example.com", "_links": {...}}, ...], "version": "2.0"}

Strategy 3: Query Parameter Versioning

# query_versioning.py
# API versioning via query parameter

from flask import Flask, jsonify, request

app = Flask(__name__)

@app.route('/api/users', methods=['GET'])
def list_users():
    """Versioned endpoint using query parameter (?version=1 or ?version=2)."""
    version = request.args.get('version', '2')  # Default to v2

    users_data = {
        1: {'name': 'Alice', 'email': 'alice@example.com', 'role': 'admin'},
        2: {'name': 'Bob', 'email': 'bob@example.com', 'role': 'user'},
    }

    if version == '1':
        result = [
            {'id': uid, 'name': u['name']}
            for uid, u in users_data.items()
        ]
        return jsonify({'users': result, 'count': len(result), 'version': '1.0'})

    elif version == '2':
        result = [
            {'name': u['name'], 'email': u['email'], 'role': u['role']}
            for uid, u in users_data.items()
        ]
        return jsonify({'data': result, 'version': '2.0'})

    return jsonify({'error': f'Unsupported version: {version}'}), 400

Testing:

curl "http://localhost:5000/api/users?version=1"
# {"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}], "count": 2, "version": "1.0"}

curl "http://localhost:5000/api/users?version=2"
# {"data": [{"name": "Alice", "email": "alice@example.com", "role": "admin"}, ...], "version": "2.0"}

Breaking vs Non-Breaking Changes

# breaking_changes.py
# Classify API changes by compatibility level

class ApiChange:
    """Classify changes as breaking or non-breaking."""

    @staticmethod
    def is_breaking_change(change_type):
        """Check if a change type breaks backward compatibility."""
        breaking_changes = {
            # Removing functionality
            'remove_endpoint',
            'remove_required_field',
            'remove_optional_field',  # Still breaking if consumers depend on it
            'rename_field',
            'change_field_type',
            'change_enum_values',
            'add_required_field',  # Breaks existing POST bodies
            'change_auth_requirement',
            'change_rate_limit',
            'change_error_format',
            'change_status_code',
            'make_field_required',
        }

        non_breaking_changes = {
            'add_endpoint',
            'add_optional_field',
            'add_enum_values',
            'deprecate_field',  # With notice
            'add_response_header',
            'change_response_order',  # If not semantically meaningful
            'increase_rate_limit',
            'improve_error_message',
        }

        if change_type in breaking_changes:
            return True
        elif change_type in non_breaking_changes:
            return False
        else:
            print(f"Unknown change type: {change_type}. Default to breaking.")
            return True

    @staticmethod
    def describe_change(change_type, detail=''):
        """Print a change classification."""
        breaking = ApiChange.is_breaking_change(change_type)
        icon = "🔴 BREAKING" if breaking else "🟢 NON-BREAKING"
        print(f"{icon}: {change_type} {detail}")

# Examples
ApiChange.describe_change('remove_endpoint', '/api/v1/legacy')
ApiChange.describe_change('add_endpoint', '/api/v2/search')
ApiChange.describe_change('add_optional_field', 'middle_name in response')
ApiChange.describe_change('rename_field', 'userName → username')
ApiChange.describe_change('add_required_field', 'api_key in request body')

Expected output:

🔴 BREAKING: remove_endpoint /api/v1/legacy
🟢 NON-BREAKING: add_endpoint /api/v2/search
🟢 NON-BREAKING: add_optional_field middle_name in response
🔴 BREAKING: rename_field userName → username
🔴 BREAKING: add_required_field api_key in request body

Deprecation Policy

# deprecation.py
# API deprecation handling with sunset headers

from flask import Flask, jsonify, request, make_response
from datetime import datetime, timedelta
import warnings

app = Flask(__name__)

# Track deprecated versions
DEPRECATED_VERSIONS = {
    'v1': {
        'deprecated_at': '2026-03-01',
        'sunset_date': '2026-09-01',
        'migration_guide': '/docs/migration-v1-to-v2',
        'changelog': '/docs/changelog#v1-deprecation',
    }
}

def add_deprecation_headers(response, version):
    """Add deprecation and sunset headers to response."""
    dep_info = DEPRECATED_VERSIONS.get(version)
    if dep_info:
        response.headers['Deprecation'] = dep_info['deprecated_at']
        response.headers['Sunset'] = dep_info['sunset_date']
        response.headers['Link'] = (
            f'<{dep_info["migration_guide"]}>; rel="migration", '
            f'<{dep_info["changelog"]}>; rel="changelog"'
        )
        response.headers['Warning'] = (
            f'299 api.dodatech.com "v1 is deprecated. '
            f'Migrate to v2 by {dep_info["sunset_date"]}."'
        )
    return response

@app.route('/api/v1/users/<int:user_id>', methods=['GET'])
def v1_get_user(user_id):
    """Deprecated v1 endpoint."""
    users = {1: {'name': 'Alice', 'email': 'alice@old-format.com'}}
    user = users.get(user_id)

    if not user:
        return jsonify({'error': 'Not found'}), 404

    response = make_response(jsonify(user))
    response = add_deprecation_headers(response, 'v1')
    return response

# Monitoring: track which clients still use deprecated versions
def log_version_usage(version, client_id):
    """Log which clients are using deprecated versions."""
    print(f"[VERSION USAGE] Client {client_id} using {version}")
    # In production: increment a metric in Prometheus/Datadog
    # metrics.increment(f'api.version.{version}.usage', tags=[f'client:{client_id}'])

Expected deprecation headers:

HTTP/1.1 200 OK
Deprecation: 2026-03-01
Sunset: 2026-09-01
Link: </docs/migration-v1-to-v2>; rel="migration", </docs/changelog#v1-deprecation>; rel="changelog"
Warning: 299 api.dodatech.com "v1 is deprecated. Migrate to v2 by 2026-09-01."

Backward Compatibility Layer

# compatibility.py
# Adapter to maintain backward compatibility between versions

def v2_to_v1_adapter(v2_response):
    """Transform v2 API response to v1 format for backward compat."""
    v1_users = []

    for user in v2_response.get('data', []):
        v1_users.append({
            'id': user.get('id'),
            'name': user.get('name'),
            'email': user.get('email'),
            # v1 expected 'email' at top level, v2 has it nested
        })

    return {
        'users': v1_users,
        'count': len(v1_users),
        'version': '1.0-compat',
    }

def v1_to_v2_adapter(v1_request):
    """Transform v1 request format to v2 for internal processing."""
    # v1 sends: {"name": "Alice", "email": "alice@example.com"}
    # v2 expects: {"user": {"name": "Alice", "email": "alice@example.com", "role": "user"}}
    v1_data = v1_request.get_json()
    return {
        'user': {
            'name': v1_data.get('name'),
            'email': v1_data.get('email'),
            'role': 'user',  # v2 requires role; default for v1 clients
        }
    }

# Usage in API gateway
def handle_versioned_request(request):
    """Route and adapt requests based on version."""
    version = extract_version(request)

    if version == 'v1':
        adapted = v1_to_v2_adapter(request)
        v2_response = forward_to_v2_api(adapted)
        return v2_to_v1_adapter(v2_response)

    elif version == 'v2':
        return forward_to_v2_api(request)

    return {'error': 'Unknown version'}, 400

Common Errors

1. Not Versioning from the Start

Adding versioning after consumers exist is painful. Consumers must update their code and you must support both old and new formats simultaneously. Version your API from day one, even if it feels unnecessary.

2. Breaking Changes Without Deprecation

Removing an endpoint or changing response format without notice breaks every consumer. Announce deprecation months in advance, provide migration guides, and keep the old version running during the transition.

3. Using String Version Comparisons

Comparing versions as strings ("2" > "10"True because “2” > “1”) is wrong. Use semantic versioning (semver) and proper comparison logic.

4. Not Providing a Migration Guide

Consumers need to know what changed, why, and how to update. Without clear migration docs, they’ll stick with the old version until it’s shut down, and then panic.

5. Forgetting to Monitor Old Versions

Without monitoring which clients still use deprecated versions, you’ll be surprised when sunset arrives. Track version in your API analytics and proactively reach out to lagging consumers.

6. Overversioning

Creating a new version for every minor change creates version fatigue (v21, v22, v23…). Only create new versions for breaking changes. Extend existing versions with optional fields for non-breaking additions.

Practice Questions

1. What are the three main API versioning strategies?

URL path (/v1/, /v2/), Accept header (application/vnd.company.v1+json), and query parameter (?version=1). URL versioning is most common; header versioning is more RESTful; query parameter is simplest but clutters URLs.

2. What makes a change breaking vs non-breaking?

Breaking: removing endpoints/fields, renaming fields, changing types, adding required fields, changing auth. Non-breaking: adding endpoints, adding optional fields, adding enum values, improving error messages.

3. How long should you support a deprecated API version?

Minimum 6-12 months after deprecation announcement. Provide clear sunset dates and monitor consumer migration progress. Extend if significant consumers haven’t migrated.

4. What HTTP headers indicate API deprecation?

Deprecation (date deprecated), Sunset (date removed), Link (migration guide URLs), and Warning (human-readable deprecation notice).

5. Challenge: Design an API versioning strategy for a public API with the following requirements: (a) 500+ active third-party integrations, (b) major version releases every 6-8 months, (c) consumers who don’t update for 12+ months, (d) need to support at least 2 major versions simultaneously.

Use URL versioning (/v1/, /v2/) for clarity. Maintain a backward compatibility layer (adapter pattern) to transform v1 requests to v2 internally. Set a 12-month deprecation window. Use Sunset headers and email notifications at 6, 3, and 1 month before sunset. Track version usage per client and proactively support migration for high-value integrations.

Mini Project: API Version Router

# version_router.py
# API Gateway that routes to the correct version handler

from flask import Flask, jsonify, request
import re

app = Flask(__name__)

class VersionRouter:
    """Route API requests to the correct version handler."""

    def __init__(self):
        self.handlers = {}  # (version, path) → handler function

    def register(self, version, path, handler):
        """Register a handler for a specific version and path."""
        key = (version, path)
        self.handlers[key] = handler
        print(f"Registered: {version} {path}")

    def route(self, request):
        """Route a request to the appropriate version handler."""
        # Try URL version first (/v2/users)
        url_match = re.match(r'/api/(v\d+)(/.*)', request.path)
        if url_match:
            version = url_match.group(1)
            path = url_match.group(2).rstrip('/') or '/'

            handler = self.handlers.get((version, path))
            if handler:
                return handler(request)

            # Try default handler for the version
            handler = self.handlers.get((version, '/'))
            if handler:
                return handler(request)

        # Try header versioning
        accept = request.headers.get('Accept', '')
        header_match = re.search(r'vnd\.dodatech\.(v\d+)\+json', accept)
        if header_match:
            version = header_match.group(1)
            path = request.path.rstrip('/') or '/'

            handler = self.handlers.get((version, path))
            if handler:
                return handler(request)

        return jsonify({
            'error': 'Not found',
            'message': f'No handler for {request.method} {request.path} '
                       f'with Accept: {request.headers.get("Accept")}'
        }), 404

# Initialize router
router = VersionRouter()

# Register v1 handlers
def v1_users_handler(req):
    return jsonify({'users': [{'id': 1, 'name': 'Alice'}], 'version': '1.0'})

def v1_user_handler(req, user_id):
    return jsonify({'id': user_id, 'name': 'Alice', 'version': '1.0'})

router.register('v1', '/users', v1_users_handler)
router.register('v1', '/users/<int:user_id>', v1_user_handler)

# Register v2 handlers
def v2_users_handler(req):
    return jsonify({
        'data': [{'name': 'Alice', 'email': 'alice@example.com'}],
        'version': '2.0'
    })

router.register('v2', '/users', v2_users_handler)

@app.route('/api/<version>/users', methods=['GET'])
@app.route('/api/<version>/users/<int:user_id>', methods=['GET'])
def handle_request(version, user_id=None):
    """Route to the correct handler based on version."""
    if user_id:
        # Create a modified request-like object
        return router.handlers.get((version, f'/users/<int:user_id>'), lambda r: jsonify({'error': 'Not found'}))(request)
    return router.route(request)

if __name__ == '__main__':
    print("API Version Router running on :5000")
    app.run(port=5000, debug=True)

FAQ

Which API versioning strategy is most popular?
URL versioning (/v1/, /v2/) is the most common and simplest to implement. It’s explicit, easy to test, and works with any client. Header versioning is more “pure REST” but harder to test and debug.
Should I use integers or dates for versions?
Integers (v1, v2, v3) are simpler and indicate ordering. Dates (2026-01, 2026-06) work well for annual versioning. Avoid floating-point version numbers (2.1, 2.2) in URLs — they suggest minor versions which shouldn’t be breaking.
How do I handle versioning for error responses?
Error responses should also be versioned. Changing error formats (field names, status codes) between versions is a breaking change. Document the error format per version.
Can I have multiple versions active at the same time?
Yes, running multiple versions simultaneously is standard practice. Route requests to the appropriate version and maintain separate code paths or adapters. Monitor and eventually sunset old versions.
What’s the alternative to API versioning?
Backward-compatible design: never remove fields, only add optional ones. Use GraphQL (consumers request exactly what they need). Use evolvable API design with hateoas links. But for most public APIs, explicit versioning is safer.

Related Concepts

What’s Next

You now master API versioning! Next, learn RESTful API design for building well-structured APIs, then explore OAuth 2.0 for securing them.

  • Practice daily — Add versioning headers to an existing API and test with curl
  • Build a project — Build an API gateway that routes, adapts, and monitors multiple API versions
  • Explore related topics — Check out GraphQL (no versioning needed), OpenAPI/Swagger for documentation, and API changelogs best practices

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. Updated 2026-06-20.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro