System Design Fundamentals

API Versioning Strategies

A

API Versioning Strategies

The Versioning Dilemma

Imagine you’ve built a public API with 500 active consumers across dozens of companies. Your /users endpoint returns a response object that’s served millions of requests daily. Then product asks: “Can we add the user’s subscription tier to the response?” Seems reasonable. But here’s the problem: if you just add the field, every consumer works fine. But what if a consumer has brittle parsing logic? What if they’re storing responses in a database and the schema suddenly changes? What if they’re enforcing strict field validation?

The answer isn’t simple: you can’t break all your consumers. But you also can’t freeze your API forever. You need technical debt, you need to remove legacy fields that are rarely used, you need to optimize response structures, and you need to redesign endpoints as your system evolves. This is where API versioning enters the picture.

API versioning is your contract with consumers: “We promise to maintain stability for version X while we innovate in version X+1.” But choose your versioning strategy poorly, and you’ll either fragment your API maintenance burden across dozens of versions, make caching impossible, or force consumers to perform gymnastics just to call your API.

This chapter explores the versioning strategies that actually work in production systems, the decisions you need to make before you pick one, and the common mistakes that turn versioning from a solution into a liability.

Understanding Breaking Changes

Before we talk about versioning strategies, we need to define what we’re trying to solve. Not all changes to an API require a new version.

Non-breaking changes are safe to deploy immediately:

  • Adding a new optional field to a response
  • Adding a new optional query parameter
  • Adding an entirely new endpoint
  • Adding new enum values to existing fields (if consumers ignore unknown values)
  • Making a previously-required field optional

Breaking changes require versioning:

  • Removing a field from a response
  • Changing a field’s type (integer to string, object to array)
  • Changing a field’s name or structure
  • Changing endpoint behavior (what data gets returned, filtering logic, sorting order)
  • Changing HTTP status codes for certain conditions
  • Renaming or removing an endpoint entirely
  • Changing authentication requirements or permission logic

The subtlety here is crucial: your API design determines how often you need new versions. APIs that are highly disciplined about backwards compatibility (always additive, never removing) can go years without a major version bump. APIs that treat versions as refactoring opportunities create version churn and maintenance hell.

Pro tip: Version your API conservatively. Avoid the temptation to use versioning as an excuse to redesign endpoints every few months. Your goal is stability, not perfection.

The Road Construction Analogy

Imagine your API is a highway system. When you build a new highway (v2), you don’t demolish the existing road (v1) overnight. Instead, you run both in parallel. You post signs directing traffic to the new route. You monitor which roads still carry heavy traffic. You decommission the old road only after traffic has migrated and you’ve maintained both for a reasonable period.

The key insight: the transition period is where API versioning succeeds or fails. Run it for too short and you strand users unable to migrate. Run it for too long and you pay the maintenance cost forever. This is why your versioning strategy must include a clear deprecation policy, not just a way to route requests.

Four Major Versioning Strategies

URI Path Versioning

The most widely-adopted approach: embed the version in the URL path.

/v1/users
/v2/users
/api/v1/products
/api/v2/products

How it works: Your router examines the first path segment (or a specific segment) and routes to different code paths, service deployments, or API gateway backends.

Adoption: Stripe, GitHub, Twilio, AWS, Google Maps all use this strategy.

Pros:

  • Explicit and discoverable — you can see the version in the browser URL
  • Easy to route at the API gateway layer
  • Cache-friendly — different versions have different URLs
  • Simple load balancing and traffic analysis
  • Makes it obvious which version a consumer is using

Cons:

  • URL pollution — every endpoint gets duplicated across versions
  • Semantically debatable — REST zealots argue you’re not really versioning resources
  • Requires careful planning if you want resource-level versioning (some endpoints in v1, others in v2)
  • Can create massive code duplication if not structured carefully

Real-world implementation: Stripe routes /v1/ requests through their API gateway to stable service code that’s been battle-tested for years, while /v2/ experimental endpoints go to newer services. They’ll eventually promote v2 to v1 once it’s stable.

Header Versioning

Embed the version in a custom header or the Accept header.

GET /users HTTP/1.1
Accept: application/vnd.api+json;version=2

or

GET /users HTTP/1.1
X-API-Version: 2

How it works: Your middleware examines the version header and routes to the appropriate handler.

Adoption: Some internal APIs, GitHub’s GraphQL-era APIs, and APIs designed for mobile clients that benefit from cleaner URIs.

Pros:

  • Clean URIs — the same path works for multiple versions
  • Resource-centric versioning — you can version individual resources independently
  • Follows HTTP specifications (Accept header is designed for content negotiation)
  • Invisible to caching layers that don’t understand custom headers

Cons:

  • Not human-friendly — you can’t type the URL in a browser and see results
  • Harder to test with tools like curl and Postman (require header flags)
  • Complicates caching — proxies and CDNs may cache the same URL with different representations
  • Less discoverable — API documentation must be very clear about headers
  • Many developers forget to set headers, requiring API gateway fallbacks to default versions

Query Parameter Versioning

Add the version as a query parameter.

GET /users?version=2
GET /users?v=2
GET /api/users?api-version=2023-02-01

How it works: Your request handler examines the query parameter and branches logic accordingly.

Pros:

  • Easy to test in a browser
  • Simple to implement
  • Works with caching if you configure cache keys properly

Cons:

  • Not truly RESTful (version is metadata, not a resource)
  • Can conflict with legitimate query parameters
  • Semantically weird for POST/PUT/DELETE operations
  • Caching is trickier because the same URL serves different content

Semantic Versioning and Deprecation

Most production APIs adopt semantic versioning: MAJOR.MINOR.PATCH

  • MAJOR version: incompatible API changes (breaking changes)
  • MINOR version: new functionality in a backwards-compatible manner
  • PATCH version: backwards-compatible bug fixes

Your versioning strategy determines which parts you actually expose. URI path versioning typically only exposes MAJOR versions (/v1/, /v2/). You might bump MINOR and PATCH versions on the server, but clients don’t need to know about them.

Deprecation lifecycle (the critical part):

  1. Announcement phase: You announce in your API changelog that endpoint X will be retired on DATE. You update documentation.
  2. Deprecation warning phase: You include the Sunset header in responses (RFC 8594) and deprecation warnings in response bodies.
  3. Grace period: Typically 6-12 months depending on your API’s maturity and consumer base. Consumer-facing APIs at major tech companies run this for 18+ months. Internal APIs at startups might do 3 months.
  4. Retirement: You disable the old endpoint. Requests get a 410 Gone status.
HTTP/1.1 200 OK
Sunset: Sun, 15 Dec 2024 23:59:59 GMT
Deprecation: true
Warning: 299 - "This API version is deprecated and will be retired on 2024-12-15"
X-API-Warn: version-sunset

A well-documented deprecation timeline gives consumers time to migrate while signaling that you’re serious about moving forward.

Implementation Patterns in the Wild

URI Path Versioning with API Gateway

Here’s how to implement URI path versioning with nginx as an API gateway:

upstream v1_backend {
    server api-v1:8000;
}

upstream v2_backend {
    server api-v2:8000;
}

server {
    listen 80;

    location /v1/ {
        proxy_pass http://v1_backend;
        proxy_set_header X-API-Version 1;
        add_header Cache-Control "public, max-age=3600";
    }

    location /v2/ {
        proxy_pass http://v2_backend;
        proxy_set_header X-API-Version 2;
        add_header Cache-Control "public, max-age=600";
    }

    # Default to v2 for new requests, v1 for legacy
    location /users {
        return 301 /v2/users;
    }
}

This configuration routes /v1/ requests to a stable service running proven code, while /v2/ requests go to the newer service. You can deploy these independently, scale them differently, and retire v1 when migration is complete.

Header Versioning with Express.js

const express = require('express');
const app = express();

app.use((req, res, next) => {
  const apiVersion = req.get('X-API-Version') || req.get('Accept')?.match(/version=(\d+)/)?.[1] || '2';
  req.apiVersion = parseInt(apiVersion, 10);
  next();
});

app.get('/users/:id', (req, res) => {
  const user = fetchUser(req.params.id);

  if (req.apiVersion === 1) {
    // Old format: flat structure
    res.json({
      id: user.id,
      name: user.name,
      email: user.email,
      subscription_type: user.subscription?.type,
      subscription_expires: user.subscription?.expiresAt,
    });
  } else {
    // New format: nested objects
    res.json({
      id: user.id,
      name: user.name,
      email: user.email,
      subscription: {
        type: user.subscription?.type,
        expiresAt: user.subscription?.expiresAt,
      },
    });
  }
});

app.listen(3000);

This approach works but creates maintenance burden — your handlers grow conditional logic for each version. This is manageable for 2-3 versions; beyond that, you should deploy separate services.

Detecting Breaking Changes with Contract Testing

The best way to prevent surprise breaking changes is contract testing. Here’s a simple example using Pact:

// consumer-test.js
const { Pact } = require('@pact-foundation/pact');

const provider = new Pact({ consumer: 'Web Client', provider: 'User API' });

describe('User API Contract', () => {
  it('should return user with expected fields', async () => {
    await provider.addInteraction({
      state: 'a user with id 123 exists',
      uponReceiving: 'a request for user 123',
      withRequest: { method: 'GET', path: '/users/123' },
      willRespondWith: {
        status: 200,
        body: {
          id: 123,
          name: 'Alice',
          email: '[email protected]',
          // If you add new optional fields here, they're non-breaking
          tier: 'premium', // non-breaking addition
        },
      },
    });

    const user = await fetch('/users/123').then(r => r.json());
    expect(user).toHaveProperty('id');
    expect(user).toHaveProperty('email'); // Breaking change if removed
  });
});

// provider-test.js verifies the actual API satisfies this contract

Run contract tests in CI/CD before deploying. If a provider change violates the contract, your build fails before the breaking change reaches production.

Making the Version Choice Decision

StrategySimplicityDiscoverabilityCachingFlexibilityUse When
URI PathVery SimpleExcellentPerfectGoodPublic APIs, multiple consumer types, need clear version in URLs
HeaderSimpleFairTrickyExcellentStable APIs, resource-level versioning, mobile clients
Query ParamVery SimpleGoodTrickyFairInternal APIs, simple versioning needs
Deprecation PolicyComplexGoodN/AExcellentAll strategies (this is orthogonal)

Choose URI path versioning if:

  • You’re building a public API with diverse consumers
  • You want maximum cacheability
  • You plan to support multiple versions simultaneously
  • You want developers to immediately see the version they’re using

Choose header versioning if:

  • You’re okay with slightly more complex client code
  • You want resource-level versioning flexibility
  • Your primary consumers are sophisticated (native apps, other services)
  • You value clean URLs over discoverability

Choose query parameter versioning if:

  • You’re building an internal API with simple needs
  • You want minimal implementation complexity
  • You’re okay with caching complexity

Always include a deprecation policy. This is non-negotiable. Without it, you’ll support old versions forever.

The “Never Version” Approach

Some APIs avoid versioning entirely by committing to backwards compatibility. How?

  • Additive only: New fields are always optional. Never remove or rename fields. Deprecated fields are left in responses but marked as deprecated in documentation.
  • Expand contracts: Rather than changing response structure, expand what data you return. Add a nested object rather than flattening.
  • Feature flags: Use runtime feature flags to gate new behavior. Consumers opt-in to new behavior rather than being forced to upgrade.

Facebook’s Graph API famously uses this approach — they’ve avoided major versions for years by following strict backwards compatibility discipline. But this strategy trades technical debt for stability. Eventually, old fields rot in your codebase, customers get confused by deprecated fields in the response, and your API contract becomes harder to reason about.

GraphQL addresses versioning differently: instead of major API versions, you deprecate individual fields and queries. Clients see deprecation warnings in their IDE as they work, and you can retire fields gradually. This is elegant but requires clients to adopt GraphQL tooling.

Key Takeaways

  • Plan for deprecation before you implement versioning. Your versioning strategy is only half the battle; the deprecation timeline determines success.
  • Use URI path versioning for public APIs. It’s explicit, cacheable, and widely understood. Reserve header versioning for sophisticated APIs with strict backwards compatibility requirements.
  • Avoid versioning if possible. Build your API with a strong commitment to backwards compatibility. Use feature flags to gate new behavior. Version only when you truly need to break changes.
  • Run multiple versions simultaneously, but with a sunset policy. Support v1 and v2 in parallel, but announce the end-of-life date for v1 clearly and enforce it. Indefinite version support is a maintenance burden that grows exponentially.
  • Implement contract testing in your CI/CD pipeline. Catch breaking changes before they reach production. Contract tests are the difference between confident API evolution and wake-up-at-3am incidents.
  • Monitor version adoption. Track which versions your consumers actually use. Use this data to decide when to retire old versions. Never retire a version without evidence that traffic has migrated.

Practice Scenarios

Scenario 1: The Regulatory Change Your payments API currently returns currency as a two-letter code: "currency": "USD". New regulatory requirements demand you include the currency’s numeric code: "currencyNumeric": 840. Your existing consumers parse the response field-by-field. How would you handle this change without breaking existing consumers? Does your chosen versioning strategy affect the answer?

Scenario 2: The Structure Redesign Your user profile endpoint currently returns { id, name, email, address_street, address_city, address_zip, address_country }. You want to restructure this to { id, name, email, address: { street, city, zip, country } } to be more semantic and support future address fields. How would you manage this transition? What’s your deprecation timeline? Which versioning strategy makes this easier?

Scenario 3: The Downstream Impact Your API has two major consumers: a web app you control and 200+ third-party integrations via an open platform. You discover that 150 of those third-party integrations are using an endpoint incorrectly — they pass an array when they should pass an object. If you “fix” the API to reject the malformed input, you break those integrations. Your team says, “That’s their bug, let them fix it.” Your product team says, “We can’t break our partners.” How does your versioning strategy help here?

What’s Next

API versioning solves the evolution problem, but it doesn’t address how clients consume your APIs. Once you’ve versioned your endpoints, you need to handle pagination (what if your users endpoint returns 10,000 results?), filtering (what if your clients only want active users?), and sorting (what order should results come back in?). In the next chapter, we’ll explore these practical pagination and filtering strategies that every API needs to scale.