APIs are contracts. Breaking changes break trust. But APIs must evolve—new features, better designs, deprecated endpoints. The question isn’t whether to change, but how to change without leaving clients stranded.

Why Versioning Matters

Without versioning, you have two bad options:

  1. Never change: Your API calcifies, accumulating cruft forever
  2. Change freely: Clients break unexpectedly, trust erodes

Versioning gives you a third path: evolve deliberately, with clear communication and migration windows.

Versioning Strategies

URL Path Versioning

The most explicit approach—version in the URL:

GGEETTv12//uusseerrss//112233

Implementation:

1
2
3
4
5
6
// Express.js
const v1Router = require('./routes/v1');
const v2Router = require('./routes/v2');

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

Pros:

  • Explicit and visible
  • Easy to route and test
  • Clear in documentation and logs

Cons:

  • URL changes when version changes
  • Can lead to code duplication
  • Caching varies by URL

Header Versioning

Version in a custom header:

GAEcTce/puts-eVresr/s1i2o3n:v2

Or using content negotiation:

GAEcTce/puts:erasp/p1l2i3cation/vnd.myapi.v2+json

Implementation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
app.use((req, res, next) => {
  const version = req.headers['accept-version'] || 'v1';
  req.apiVersion = version;
  next();
});

app.get('/users/:id', (req, res) => {
  if (req.apiVersion === 'v2') {
    return handleV2(req, res);
  }
  return handleV1(req, res);
});

Pros:

  • Clean URLs
  • Same resource, different representations
  • Better REST semantics

Cons:

  • Less visible/discoverable
  • Harder to test in browser
  • Easy to forget the header

Query Parameter Versioning

Version as a query parameter:

GET/users/123?version=2

Pros:

  • Easy to implement
  • Visible in URLs
  • Optional with defaults

Cons:

  • Pollutes query string
  • Can conflict with other parameters
  • Feels like a workaround

My Recommendation: URL Versioning

For most APIs, URL path versioning wins:

  • Explicit is better than implicit
  • Easy to document, test, and debug
  • Clear routing in infrastructure (load balancers, API gateways)
  • Clients know exactly what they’re calling

Reserve header versioning for APIs where URL stability matters more than explicitness (e.g., hypermedia APIs).

What Counts as a Breaking Change?

Breaking (requires new version):

  • Removing a field from response
  • Removing an endpoint
  • Changing field type (string → number)
  • Changing required/optional status
  • Changing authentication requirements
  • Changing error response format

Non-breaking (safe in current version):

  • Adding new optional fields to response
  • Adding new endpoints
  • Adding new optional parameters
  • Adding new enum values (if clients handle unknown values)
  • Performance improvements
  • Bug fixes

Backward-Compatible Evolution

Before jumping to a new version, try evolving compatibly:

Adding Fields

Safe—old clients ignore new fields:

1
2
3
4
5
// v1 response
{ "id": 123, "name": "Alice" }

// v1 response with new field (still v1!)
{ "id": 123, "name": "Alice", "email": "alice@example.com" }

Deprecating Fields

Mark as deprecated, keep returning them:

1
2
3
4
5
6
{
  "id": 123,
  "name": "Alice",
  "fullName": "Alice Smith",  // New field
  "name_deprecated": true     // Signal to migrate
}

Or use response headers:

DSLeuipnnrskee:cta:tiSvoa2nt/:,ust0er1ruseJ>u;nr2e0l2=6"s0u0c:c0e0s:s0o0r-GvMeTrsion"

Optional Parameters with Defaults

Add new functionality via optional parameters:

#G#GEEOTNTled/w/uubsbseeeehrhrasasv?v?isisoooorrrrtt(==dcocerprfeteaa-autitlenetd)d)__aatt&include=metadata

Version Lifecycle

Define clear lifecycle stages:

Curvr3entStavb2leDeprevc1atedSu(ngsoente)

Current: Active development, new features Stable: Maintained, security fixes only Deprecated: Sunset date announced, migration docs available Sunset: Removed, returns 410 Gone

Communicate Clearly

1
2
3
4
5
6
7
8
9
// Deprecated version response
app.use('/v1', (req, res, next) => {
  res.set({
    'Deprecation': 'true',
    'Sunset': 'Sat, 01 Jun 2026 00:00:00 GMT',
    'X-API-Warn': 'v1 is deprecated. Migrate to v2 by June 2026.',
  });
  next();
});

Implementation Patterns

Shared Core, Version Adapters

Don’t duplicate business logic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Core logic (version-agnostic)
class UserService {
  async getUser(id) {
    return await db.users.findById(id);
  }
}

// v1 adapter
function formatUserV1(user) {
  return {
    id: user.id,
    name: user.fullName,  // v1 called it "name"
  };
}

// v2 adapter  
function formatUserV2(user) {
  return {
    id: user.id,
    fullName: user.fullName,
    email: user.email,
    createdAt: user.createdAt.toISOString(),
  };
}

// Routes use adapters
app.get('/v1/users/:id', async (req, res) => {
  const user = await userService.getUser(req.params.id);
  res.json(formatUserV1(user));
});

app.get('/v2/users/:id', async (req, res) => {
  const user = await userService.getUser(req.params.id);
  res.json(formatUserV2(user));
});

API Gateway Routing

Let infrastructure handle version routing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Kong/AWS API Gateway config
routes:
  - path: /v1/*
    service: api-v1
    
  - path: /v2/*
    service: api-v2
    
  - path: /users/*  # Unversioned defaults to latest
    service: api-v2

Testing Across Versions

Contract tests ensure versions behave correctly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
describe('User API', () => {
  describe('v1', () => {
    it('returns name field', async () => {
      const res = await request(app).get('/v1/users/123');
      expect(res.body).toHaveProperty('name');
      expect(res.body).not.toHaveProperty('fullName');
    });
  });

  describe('v2', () => {
    it('returns fullName field', async () => {
      const res = await request(app).get('/v2/users/123');
      expect(res.body).toHaveProperty('fullName');
      expect(res.body).not.toHaveProperty('name');
    });
  });
});

Common Mistakes

Too many versions: Three concurrent versions is manageable. Ten is a maintenance nightmare.

No sunset policy: Old versions accumulate forever. Set expiration dates.

Breaking changes in minor versions: If you’re on v2, don’t break v2 clients. That’s what v3 is for.

No migration guide: Announcing deprecation without explaining how to migrate is hostile.

Versioning too early: Don’t ship v2 until v1 is actually limiting you.

The Mental Model

Think of API versions like software releases:

  • Major version (v1 → v2): Breaking changes, migration required
  • Minor updates within version: Backward-compatible additions
  • Deprecation period: Like end-of-life announcements
  • Sunset: Like decommissioning old software

Your API is a product. Versions are releases. Treat them with the same discipline you’d treat shipping software.

The goal isn’t to never change—it’s to change predictably, communicate clearly, and give clients time to adapt.