API Versioning

Strategies for managing breaking changes in APIs while maintaining backward compatibility.

API versioning allows you to evolve your API while maintaining backward compatibility for existing clients. When you need to make breaking changes, versioning lets old clients continue working while new clients use updated functionality.

Why Version Your API

Breaking changes happen: Renaming fields, changing data types, removing endpoints - these break existing clients.

Clients can't update instantly: Mobile apps need app store approval. Third-party integrations need development time.

Versioning provides:

  • Stability for existing clients
  • Freedom to improve the API
  • Clear migration path
  • Time for clients to upgrade

What's a Breaking Change?

BreakingNon-Breaking
Removing a fieldAdding a new field
Renaming a fieldAdding a new endpoint
Changing field typeAdding optional parameter
Removing an endpointDeprecating with warning
Changing error formatAdding new error codes
Changing authenticationExtending response data

Rule: If existing clients will fail without code changes, it's breaking.

Versioning Strategies

URL Path Versioning: /v1/users, /v2/users Most common. Easy to understand, route, and cache. Clear which version you're using.

Query Parameter: /users?version=1 Keeps URLs clean. Harder to route and cache. Can be forgotten easily.

Header Versioning: Accept: application/vnd.api+json;version=1 Clean URLs. Version hidden in headers. More complex to test and debug.

Content Negotiation: Accept: application/vnd.company.v1+json REST purist approach. Complex to implement and document.

Choosing a Strategy

StrategyProsCons
URL PathSimple, visible, cacheableURL changes between versions
Query ParamClean URLsEasy to forget, caching issues
HeaderClean URLs, flexibleHidden, harder to test

Recommendation: Use URL path versioning unless you have specific reasons not to. It's the most widely used and understood approach.

Version Lifecycle

1. Active: Current recommended version. Full support.

2. Deprecated: Still works but scheduled for removal. Log warnings, notify users.

3. Sunset: End of life date announced. Return sunset headers.

4. Removed: Returns 410 Gone or redirects to docs.

Best Practices

Version at API level, not endpoint: All v1 endpoints should behave consistently. Don't version individual endpoints differently.

Document migration guides: When releasing v2, provide clear guide for upgrading from v1.

Set deprecation timeline: "v1 deprecated January 2024, sunset April 2024" - give clients time to migrate.

Use sunset headers: Sunset: Sat, 01 Apr 2024 00:00:00 GMT Clients can automate migration warnings.

Monitor version usage: Track which clients use which versions. Contact heavy v1 users before sunsetting.

Don't over-version: Every version is maintenance burden. Make breaking changes rare and bundle them.

Avoiding Breaking Changes

Additive changes only: Add new fields, don't remove or rename existing ones.

Use nullable fields: New optional fields with defaults don't break existing clients.

Expand enums carefully: Adding enum values can break strict client validation.

Deprecate before removing: Mark fields deprecated in v1 before removing in v2.

Common Mistakes

1. Too many versions: Supporting v1-v10 is expensive. Limit active versions to 2-3.

2. No deprecation period: Killing a version immediately breaks clients. Give 6-12 months notice.

3. Inconsistent versioning: Some endpoints versioned, some not. Version everything consistently.

4. Breaking changes in minor versions: v1.1 should be backward compatible with v1.0.

5. No version in initial release: Start with /v1 from day one. Adding versioning later is harder.

Semantic Versioning for APIs

Major (v1 → v2): Breaking changes. Clients must update code.

Minor (v1.0 → v1.1): New features, backward compatible.

Patch (v1.0.0 → v1.0.1): Bug fixes, no API changes.

Use major version in URL (/v1, /v2), document minor/patch in changelog.

Code Examples

API Versioning Implementation

// Express router with versioned routes
import { Router } from 'express';

// Version-specific routers
const v1Router = Router();
const v2Router = Router();

// V1: Original user response
v1Router.get('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json({
    id: user.id,
    name: user.fullName,  // Old field name
    email: user.email
  });
});

// V2: Updated user response
v2Router.get('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json({
    id: user.id,
    firstName: user.firstName,  // Split name field
    lastName: user.lastName,
    email: user.email,
    createdAt: user.createdAt  // New field
  });
});

// Mount versioned routers
app.use('/v1', v1Router);
app.use('/v2', v2Router);

// Deprecation middleware for v1
app.use('/v1', (req, res, next) => {
  res.set('Deprecation', 'true');
  res.set('Sunset', 'Sat, 01 Apr 2025 00:00:00 GMT');
  res.set('Link', '</v2>; rel="successor-version"');
  next();
});