MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

API Versioning Strategies: URL, Header, and Query Parameter

Implement API versioning: strategies, backward compatibility, and deprecation policies.

APIVersioningRESTBackend

By MinhVo

Introduction

API versioning is one of the most debated topics in API design. How do you evolve your API without breaking existing clients? When every change risks breaking integrations, a clear versioning strategy becomes essential. The approach you choose affects client developer experience, documentation complexity, caching behavior, and the long-term maintainability of your API. There's no universally correct answer — the best strategy depends on your API's consumers, the frequency of breaking changes, and your team's operational capacity.

API versioning strategies

The core challenge is backward compatibility. When you add a new field to a response, that's non-breaking. When you rename a field, change a type, remove an endpoint, or alter behavior, that's breaking. Versioning provides a mechanism to introduce breaking changes while preserving existing client integrations. The key is choosing a strategy that minimizes disruption for both API providers and consumers.

This guide covers the major versioning strategies — URL path versioning, header versioning, query parameter versioning, and content negotiation — with practical implementation patterns, deprecation strategies, and real-world trade-offs.

Understanding API Versioning: Core Concepts

Breaking vs. Non-Breaking Changes

Non-breaking changes can be deployed without version increments: adding new optional fields, adding new endpoints, adding new query parameters with defaults. Breaking changes require a new version: removing fields, changing field types, altering response structures, changing authentication methods.

URL Path Versioning

The version number is part of the URL: /api/v1/users, /api/v2/users. This is the most visible and widely used approach. It's easy to understand, easy to route, and easy to document. The downside is URL pollution and the need to maintain multiple URL trees.

Header Versioning

The version is specified in a request header: Accept-Version: 2 or X-API-Version: 2. URLs stay clean, and the versioning mechanism is decoupled from the URL structure. The downside is reduced discoverability — clients need to know to set the header.

Query Parameter Versioning

The version is a query parameter: /api/users?version=2. This is easy to implement and test in browsers but pollutes URLs and can cause caching issues.

Content Negotiation

The version is part of the Accept header: Accept: application/vnd.myapi.v2+json. This follows HTTP standards but is complex to implement and difficult to test.

Versioning strategies comparison

Architecture and Design Patterns

The Parallel Deployment Pattern

Deploy multiple API versions simultaneously. Each version has its own routing, handlers, and potentially its own database schema. This provides complete isolation but increases operational complexity.

The Shared Backend Pattern

All API versions share the same backend code with version-specific transformations. A translation layer converts between versions, mapping field names, restructuring responses, and handling deprecated fields.

The Deprecation Pipeline Pattern

Define a clear lifecycle for API versions: current (fully supported), deprecated (still working, sunset date announced), and retired (returns 410 Gone). This gives clients time to migrate while keeping the API surface manageable.

The Evolution Pattern

Avoid versioning entirely by designing APIs that evolve without breaking changes. Add new fields instead of renaming, use optional parameters instead of changing defaults, and maintain backward compatibility through careful API design.

Step-by-Step Implementation

URL Path Versioning with Express

import express from 'express';
 
const app = express();
 
// Version 1 handler
const v1Users = {
  list: (req, res) => {
    const users = getUsers();
    res.json({
      users: users.map(u => ({
        id: u.id,
        name: u.name,
        email: u.email,
      })),
    });
  },
  get: (req, res) => {
    const user = getUserById(req.params.id);
    res.json({
      id: user.id,
      name: user.name,
      email: user.email,
    });
  },
};
 
// Version 2 handler (breaking changes: name → firstName/lastName)
const v2Users = {
  list: (req, res) => {
    const users = getUsers();
    res.json({
      data: users.map(u => ({
        id: u.id,
        firstName: u.firstName,
        lastName: u.lastName,
        email: u.email,
        createdAt: u.createdAt,
      })),
      pagination: { page: 1, total: users.length },
    });
  },
  get: (req, res) => {
    const user = getUserById(req.params.id);
    res.json({
      data: {
        id: user.id,
        firstName: user.firstName,
        lastName: user.lastName,
        email: user.email,
        createdAt: user.createdAt,
      },
    });
  },
};
 
// Route mounting
app.use('/api/v1/users', v1Router);
app.use('/api/v2/users', v2Router);

Header-Based Versioning

import express from 'express';
 
interface VersionHandler {
  [version: string]: (req, res) => void;
}
 
function versionedRoute(handlers: VersionHandler, defaultVersion: string = '2') {
  return (req, res) => {
    const version = req.headers['accept-version'] || 
                    req.headers['x-api-version'] || 
                    defaultVersion;
 
    const handler = handlers[version];
    if (!handler) {
      return res.status(400).json({
        error: `Unsupported API version: ${version}`,
        supportedVersions: Object.keys(handlers),
      });
    }
 
    res.set('X-API-Version', version);
    handler(req, res);
  };
}
 
// Usage
app.get('/api/users', versionedRoute({
  '1': v1Users.list,
  '2': v2Users.list,
}));

Content Negotiation Versioning

function contentNegotiatedRoute(handlers: VersionHandler) {
  return (req, res) => {
    const accept = req.headers.accept || '';
    const versionMatch = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);
    const version = versionMatch ? versionMatch[1] : '2';
 
    const handler = handlers[version];
    if (!handler) {
      return res.status(406).json({ error: 'Not acceptable' });
    }
 
    res.set('Content-Type', `application/vnd.myapi.v${version}+json`);
    handler(req, res);
  };
}

URL Path Versioning with Spring Boot

Java/Spring Boot provides elegant versioning through annotations and request mapping:

@RestController
@RequestMapping("/api")
public class UserController {
 
    // Version 1: flat response structure
    @GetMapping("/v1/users")
    public List<UserV1Response> listUsersV1() {
        return userService.findAll().stream()
            .map(user -> UserV1Response.builder()
                .id(user.getId())
                .name(user.getFullName())
                .email(user.getEmail())
                .build())
            .collect(Collectors.toList());
    }
 
    // Version 2: structured response with pagination
    @GetMapping("/v2/users")
    public PagedResponse<UserV2Response> listUsersV2(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        Page<User> users = userService.findAll(PageRequest.of(page, size));
        return PagedResponse.<UserV2Response>builder()
            .data(users.getContent().stream()
                .map(user -> UserV2Response.builder()
                    .id(user.getId())
                    .firstName(user.getFirstName())
                    .lastName(user.getLastName())
                    .email(user.getEmail())
                    .createdAt(user.getCreatedAt())
                    .build())
                .collect(Collectors.toList()))
            .page(users.getNumber())
            .totalPages(users.getTotalPages())
            .totalElements(users.getTotalElements())
            .build();
    }
}

Query Parameter Versioning with FastAPI

Python's FastAPI makes query parameter versioning straightforward with dependency injection:

from fastapi import FastAPI, Query, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
 
app = FastAPI()
 
class UserV1(BaseModel):
    id: int
    name: str
    email: str
 
class UserV2(BaseModel):
    id: int
    first_name: str
    last_name: str
    email: str
    created_at: datetime
 
async def get_api_version(
    version: Optional[str] = Query(default="2", alias="v")
) -> str:
    supported = {"1", "2"}
    if version not in supported:
        raise HTTPException(
            status_code=400,
            detail=f"Unsupported version '{version}'. Supported: {supported}"
        )
    return version
 
@app.get("/api/users")
async def list_users(
    version: str = Depends(get_api_version),
    page: int = Query(default=1, ge=1),
    size: int = Query(default=20, ge=1, le=100),
):
    users = await user_service.list_all(skip=(page - 1) * size, limit=size)
 
    if version == "1":
        return {
            "users": [
                {"id": u.id, "name": f"{u.first_name} {u.last_name}", "email": u.email}
                for u in users
            ]
        }
 
    return {
        "data": [
            {
                "id": u.id,
                "first_name": u.first_name,
                "last_name": u.last_name,
                "email": u.email,
                "created_at": u.created_at.isoformat(),
            }
            for u in users
        ],
        "meta": {"page": page, "size": size},
    }

Deprecation Middleware

interface DeprecationInfo {
  version: string;
  sunsetDate: Date;
  migrationGuide: string;
}
 
const deprecations: Record<string, DeprecationInfo> = {
  '1': {
    version: '1',
    sunsetDate: new Date('2025-06-01'),
    migrationGuide: 'https://docs.example.com/api/migration/v1-to-v2',
  },
};
 
function deprecationMiddleware(req, res, next) {
  const version = req.headers['accept-version'] || '1';
  const deprecation = deprecations[version];
 
  if (deprecation) {
    const daysUntilSunset = Math.ceil(
      (deprecation.sunsetDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
    );
 
    res.set('Sunset', deprecation.sunsetDate.toISOString());
    res.set('Deprecation', 'true');
    res.set('Link', `<${deprecation.migrationGuide}>; rel="deprecation"`);
    res.set('X-Sunset-Notice', `This version will be retired in ${daysUntilSunset} days`);
 
    if (daysUntilSunset <= 0) {
      return res.status(410).json({
        error: 'API version retired',
        message: `Version ${version} has been retired. Please migrate to the latest version.`,
        migrationGuide: deprecation.migrationGuide,
      });
    }
  }
 
  next();
}

API versioning lifecycle

Real-World Use Cases

Public API Evolution

Public APIs (Stripe, Twilio, GitHub) use URL versioning with long deprecation periods. Stripe uses date-based versions, allowing clients to pin to a specific API version while Stripe continuously evolves the API behind the scenes.

Internal Microservice APIs

Internal APIs often skip formal versioning in favor of backward-compatible evolution. When breaking changes are needed, they deploy both old and new endpoints simultaneously and migrate consumers gradually.

Mobile App APIs

Mobile apps have a unique challenge — users don't always update their apps. The API must support multiple versions simultaneously, sometimes for years. URL versioning with long deprecation periods is the standard approach.

Partner APIs

Partner APIs (B2B integrations) require careful versioning because partners may need months to update their integrations. Header versioning with explicit deprecation timelines and migration support is common.

Best Practices for Production

  1. Prefer backward-compatible changes — Add new fields instead of renaming, add new endpoints instead of changing existing ones. This reduces the need for versioning.

  2. Use URL versioning for public APIs — It's the most visible, easiest to document, and simplest for clients to use. Reserve header versioning for internal APIs.

  3. Maintain at most 2-3 active versions — Each additional version multiplies maintenance burden. Deprecate old versions aggressively.

  4. Provide clear deprecation timelines — Give clients at least 6-12 months notice before retiring a version. Include sunset dates in response headers.

  5. Write migration guides — For each version transition, provide a clear guide documenting every change and how to update client code.

  6. Test all supported versions — Each version should have its own test suite. Ensure that changes to shared backend code don't break older versions.

  7. Version your API documentation — Each version should have its own documentation page. Clearly mark deprecated versions and link to migration guides.

  8. Use semantic versioning in documentation — Major versions for breaking changes, minor versions for new features. The API URL only includes the major version.

Common Pitfalls and Solutions

PitfallImpactSolution
Too many active versionsMaintenance nightmareLimit to 2-3 active versions
No deprecation timelineClients never migrateSet and enforce sunset dates
Breaking changes without version bumpBroken client integrationsAlways increment version for breaking changes
Version in URL fragmentDoesn't work with proxies/CDNsUse URL path or header
No migration guidesClients can't upgradeProvide detailed migration documentation
Sharing code between versionsChanges break old versionsUse version-specific transformation layers
Ignoring non-breaking evolutionUnnecessary version bumpsDesign for backward compatibility first

Performance Optimization

Optimize versioned API performance by sharing backend logic between versions and using transformation layers for version-specific formatting. Avoid maintaining completely separate codebases for each version — the duplication creates maintenance burden and inconsistency risks.

Use CDN caching carefully with versioned APIs — include the version in the cache key to prevent serving v2 responses to v1 requests.

Comparison of Versioning Strategies

StrategyVisibilityURL CleanCacheableComplexityBest For
URL Path★★★★★✗✓LowPublic APIs
Header★★★✓ComplexMediumInternal APIs
Query Parameter★★★★✗ComplexLowSimple APIs
Content Negotiation★★✓ComplexHighStandards-compliant
Date-based (Stripe)★★★★✗✓MediumContinuous evolution

Advanced Patterns

Version Negotiation

Allow clients to specify a minimum acceptable version. The server responds with the highest version that's ≤ the requested version and ≥ the minimum. This enables clients to take advantage of new features when available.

// Server-side version negotiation
app.get('/api/users', (req, res) => {
  const requestedVersion = parseInt(req.headers['x-api-version'] || '2');
  const minVersion = parseInt(req.headers['x-min-version'] || '1');
 
  // Find the highest available version within the client's range
  const availableVersions = [1, 2, 3];
  const negotiatedVersion = availableVersions
    .filter(v => v >= minVersion && v <= requestedVersion)
    .pop();
 
  if (!negotiatedVersion) {
    return res.status(400).json({
      error: 'No compatible version available',
      available: availableVersions,
      requested: requestedVersion,
      minimum: minVersion,
    });
  }
 
  res.set('X-Negotiated-Version', negotiatedVersion.toString());
  handlers[negotiatedVersion](req, res);
});

This pattern is particularly useful for mobile apps that may lag behind the latest API version. The app requests version 3 as its target but declares version 1 as its minimum, allowing the server to serve the best available response.

Automatic Version Migration

Build tooling that automatically generates version translation code from API specifications. When you define a v2 OpenAPI spec, the tool generates transformation functions that convert between v1 and v2 request/response formats.

// Auto-generated version transformer from OpenAPI diff
interface VersionTransformer {
  toV1(response: V2Response): V1Response;
  toV2(response: V1Response): V2Response;
}
 
const userTransformer: VersionTransformer = {
  toV1(v2) {
    return {
      id: v2.data.id,
      name: `${v2.data.firstName} ${v2.data.lastName}`, // v2 splits name
      email: v2.data.email,
    };
  },
  toV2(v1) {
    const [firstName, ...rest] = (v1.name || '').split(' ');
    return {
      data: {
        id: v1.id,
        firstName,
        lastName: rest.join(' '),
        email: v1.email,
        createdAt: new Date().toISOString(), // default for missing field
      },
    };
  },
};

Tools like Optic, Speakeasy, and oapi-codegen can generate these transformers from OpenAPI specification diffs, eliminating manual translation code.

GraphQL as Versioning Alternative

GraphQL's field selection model eliminates much of the need for versioning. Clients request exactly the fields they need, and new fields are added without breaking existing queries. Deprecation is built into the schema:

type User {
  id: ID!
  # @deprecated Use firstName and lastName instead
  name: String @deprecated(reason: "Use firstName and lastName")
  firstName: String!
  lastName: String!
  email: String!
  createdAt: DateTime!
}

GraphQL's @deprecated directive signals clients to migrate while keeping the field functional. Schema evolution tools like Apollo Studio track field usage across clients, showing exactly which consumers still use deprecated fields.

Feature Flag-Based Versioning

Instead of explicit version numbers, use feature flags to control API behavior per client or consumer group:

app.get('/api/users/:id', async (req, res) => {
  const user = await getUserById(req.params.id);
  const flags = await getFeatureFlags(req.apiKey);
 
  const response: any = {
    id: user.id,
    email: user.email,
  };
 
  if (flags.has('new-name-format')) {
    response.firstName = user.firstName;
    response.lastName = user.lastName;
  } else {
    response.name = `${user.firstName} ${user.lastName}`;
  }
 
  if (flags.has('include-metadata')) {
    response.createdAt = user.createdAt;
    response.updatedAt = user.updatedAt;
  }
 
  res.json(response);
});

This approach enables gradual rollouts and A/B testing of API changes without formal version numbers. LaunchDarkly, Unleash, and Flagsmith are popular feature flag platforms that support this pattern.

Implementation in Other Frameworks

FastAPI (Python) with Header Versioning

from fastapi import FastAPI, Header, HTTPException
from typing import Optional
 
app = FastAPI()
 
async def get_user_v1(user_id: int):
    user = await db.users.find_one({"id": user_id})
    return {"id": user["id"], "name": user["name"], "email": user["email"]}
 
async def get_user_v2(user_id: int):
    user = await db.users.find_one({"id": user_id})
    return {
        "data": {
            "id": user["id"],
            "first_name": user["first_name"],
            "last_name": user["last_name"],
            "email": user["email"],
            "created_at": user["created_at"].isoformat(),
        }
    }
 
@app.get("/api/users/{user_id}")
async def read_user(
    user_id: int,
    x_api_version: Optional[str] = Header(default="2"),
):
    handlers = {"1": get_user_v1, "2": get_user_v2}
    if x_api_version not in handlers:
        raise HTTPException(status_code=400, detail=f"Unsupported version: {x_api_version}")
    return await handlers[x_api_version](user_id)

Spring Boot (Java) with URL Versioning

@RestController
@RequestMapping("/api")
public class UserController {
 
    @GetMapping("/v1/users/{id}")
    public ResponseEntity<UserV1> getUserV1(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok(UserV1.builder()
            .id(user.getId())
            .name(user.getFullName())
            .email(user.getEmail())
            .build());
    }
 
    @GetMapping("/v2/users/{id}")
    public ResponseEntity<ApiResponse<UserV2>> getUserV2(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok(ApiResponse.of(UserV2.builder()
            .id(user.getId())
            .firstName(user.getFirstName())
            .lastName(user.getLastName())
            .email(user.getEmail())
            .createdAt(user.getCreatedAt())
            .build()));
    }
}

Spring Boot also supports custom @Version annotations via spring-restdocs and Spring Cloud Contract for API versioning with less boilerplate.

Future Outlook

API versioning is moving toward continuous evolution — APIs that change without explicit versions, using backward-compatible changes, feature flags, and gradual rollouts. Stripe's date-based versioning model, where clients pin to a specific API date, is gaining adoption as it enables continuous deployment without breaking clients.

The convergence of API versioning with API management platforms will automate much of the versioning lifecycle — automatically detecting breaking changes, generating migration guides, and managing deprecation timelines. Tools like Optic and Speakeasy already provide automated breaking change detection by comparing OpenAPI specifications across commits.

AI-powered migration assistants are emerging that can automatically update client code when an API version changes. Given a migration guide and client code, these tools generate pull requests that update API calls to the new version. This dramatically reduces the cost of maintaining multiple versions and accelerates client migration.

Versioning in Microservices

In microservices architectures, API versioning becomes more complex because multiple services may depend on different versions of the same API. Strategies include using an API gateway that routes requests to the appropriate service version based on the version header or URL path.

# Kong API Gateway version routing
services:
- name: users-v1
  url: http://users-service-v1:8080
  routes:
  - name: users-v1-route
    paths: ["/api/v1/users"]
    strip_path: false
 
- name: users-v2
  url: http://users-service-v2:8080
  routes:
  - name: users-v2-route
    paths: ["/api/v2/users"]
    strip_path: false

Service mesh technologies like Istio can manage traffic splitting between API versions, enabling gradual rollouts and canary deployments:

# Istio VirtualService for version-based traffic splitting
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: users-api
spec:
  hosts:
  - users-api.example.com
  http:
  - match:
    - headers:
        x-api-version:
          exact: "1"
    route:
    - destination:
        host: users-service
        subset: v1
  - match:
    - headers:
        x-api-version:
          exact: "2"
    route:
    - destination:
        host: users-service
        subset: v2
  # Default: 90% v2, 10% v1 (gradual migration)
  - route:
    - destination:
        host: users-service
        subset: v2
      weight: 90
    - destination:
        host: users-service
        subset: v1
      weight: 10

Contract Testing with Pact

Contract testing verifies that API consumers and providers agree on the interface for each version. Pact generates consumer-driven contracts that catch breaking changes before they reach production:

// Consumer test (Pact)
import { Pact } from '@pact-foundation/pact';
 
const provider = new Pact({
  consumer: 'WebApp',
  provider: 'UsersAPI',
});
 
describe('Users API v2', () => {
  beforeAll(() => provider.setup());
  afterAll(() => provider.finalize());
 
  it('returns users with v2 structure', async () => {
    await provider.addInteraction({
      state: 'users exist',
      uponReceiving: 'a request for users v2',
      withRequest: {
        method: 'GET',
        path: '/api/v2/users',
        headers: { 'Accept-Version': '2' },
      },
      willRespondWith: {
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          data: [
            {
              id: 1,
              firstName: 'John',
              lastName: 'Doe',
              email: 'john@example.com',
              createdAt: like('2024-01-01T00:00:00Z'),
            },
          ],
          pagination: { page: 1, total: like(10) },
        },
      },
    });
 
    const response = await getUsersV2();
    expect(response.data[0]).toHaveProperty('firstName');
    expect(response.data[0]).not.toHaveProperty('name'); // v1 field
  });
});

OpenAPI Diff for Breaking Change Detection

Use openapi-diff tools in CI pipelines to automatically detect breaking changes between API versions:

# Install openapi-diff
npm install -g @openapitools/openapi-diff
 
# Compare two OpenAPI specs
openapi-diff v1-openapi.yaml v2-openapi.yaml \
  --fail-on-incompatible \
  --output JSON > diff-report.json
 
# In CI pipeline
if openapi-diff main-openapi.yaml pr-openapi.yaml --fail-on-incompatible; then
  echo "No breaking changes detected"
else
  echo "BREAKING CHANGES: requires version bump"
  exit 1
fi

Document version compatibility matrices so teams understand which service versions are compatible with each other. Automated contract tests catch breaking changes before they reach production, regardless of which versioning strategy you use.

Choosing a versioning strategy early and documenting it clearly prevents breaking changes from disrupting API consumers in production environments.

API versioning is not just a technical decision but a business commitment to your consumers. Choose a strategy that aligns with your team's workflow and your API consumers' expectations, and document the deprecation policy clearly from the beginning.

Conclusion

API versioning is a critical design decision that affects every aspect of your API's lifecycle. The right strategy depends on your consumers, the frequency of breaking changes, and your team's operational capacity.

Key takeaways:

  1. Prefer backward-compatible evolution over explicit versioning when possible
  2. Use URL path versioning for public APIs — it's the most visible and easiest to use
  3. Maintain at most 2-3 active versions to control maintenance burden
  4. Provide clear deprecation timelines (6-12 months) with sunset dates in response headers
  5. Write detailed migration guides for every version transition
  6. Test all supported versions to prevent shared code from breaking older versions
  7. Consider GraphQL for APIs that need frequent evolution without versioning

Start by defining your API's breaking change policy — what constitutes a breaking change, how versions are numbered, and how long deprecated versions are supported. Implement URL path versioning with deprecation middleware, and test your migration path with a pilot client before general availability.