Versioning Strategies and Selection
Versioning Strategies and Selection
URL Path Versioning
The most widely adopted strategy for public APIs. The version number lives in the URL, making it explicit, cacheable, and easy to route:
// Express router with versioned mounts
const v1Router = express.Router();
const v2Router = express.Router();
// v1: flat response format
v1Router.get('/users', async (req, res) => {
const users = await User.findAll();
res.json({ users, total: users.length });
});
// v2: envelope with pagination metadata
v2Router.get('/users', async (req, res) => {
const { page = 1, limit = 20 } = req.query;
const offset = (page - 1) * limit;
const [users, total] = await Promise.all([
User.findAll({ limit, offset, order: [['createdAt', 'DESC']] }),
User.count()
]);
res.json({
data: users.map(u => ({ id: u.id, name: u.name, email: u.email })),
meta: { page, limit, total, totalPages: Math.ceil(total / limit) }
});
});
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
Header Versioning
Clean URLs with version info sent via the Accept header. Useful for internal APIs where you control all clients:
// Custom vendor media type: Accept: application/vnd.myapi.v2+json
app.use('/api', (req, res, next) => {
const accept = req.headers.accept || '';
const match = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);
req.apiVersion = match ? parseInt(match[1]) : 1;
next();
});
app.get('/api/users', (req, res) => {
if (req.apiVersion === 1) return res.json(usersV1Format);
if (req.apiVersion === 2) return res.json(usersV2Format);
res.status(406).json({ error: 'Unsupported API version. Use Accept: application/vnd.myapi.v2+json' });
});
Query Parameter Versioning
Simplest to implement but pollutes the URL and complicates caching:
app.get('/api/users', (req, res) => {
const version = parseInt(req.query.version) || 1;
if (version === 1) return res.json(formatV1(users));
if (version === 2) return res.json(formatV2(users));
res.status(400).json({ error: 'Invalid version parameter' });
});
Strategy Comparison
| Strategy | Cacheable | Explicit | Browser-Testable | RESTful |
|---|---|---|---|---|
| URL path | Yes (CDN-friendly) | Yes | Yes | Purists oppose |
| Header | Yes (Vary header) | No (hidden) | Harder | Yes |
| Query param | Yes | Yes | Yes | Partially |
Rule of thumb: Use URL path versioning for public APIs consumed by third parties. Use header versioning for internal APIs where you control all clients and want clean resource URIs.
Breaking vs Non-Breaking Changes and Deprecation
Breaking vs Non-Breaking Changes and Deprecation
Non-Breaking Changes (Safe to Add)
These do not require a new version — existing clients continue to work:
// Adding a new optional field to a response
// v1 response still works, new field is simply appended
{
"id": "usr_123",
"name": "Alice",
"email": "alice@example.com",
"avatarUrl": null, // NEW FIELD — clients ignore unknown fields
"createdAt": "2026-01-15T10:30:00Z"
}
// Adding a new optional query parameter
// Existing: GET /api/v1/users?status=active
// Updated: GET /api/v1/users?status=active&sort=createdAt
// Old clients still work; new clients can use the sort param
// Adding a new endpoint under the same version
app.post('/api/v1/users/:id/avatar', uploadAvatar); // NEW — no impact
Breaking Changes (Require New Version)
// 1. Renaming or removing a field
// v1: { name: "Alice" }
// v2: { profile: { firstName: "Alice" } } // BREAKING
// 2. Changing a field type
// v1: { id: 123 } (number)
// v2: { id: "usr_123" } (string) // BREAKING
// 3. Changing authentication requirements
// v1: public endpoints
// v2: all endpoints require Bearer token // BREAKING
// 4. Narrowing allowed values
// v1: status accepts "active", "inactive"
// v2: status only accepts "active" // BREAKING for inactive users
// 5. Changing error response structure
// v1: { error: "Not found" }
// v2: { error: { code: "NOT_FOUND", message: "Resource not found" } } // BREAKING
Deprecation Workflow
Use HTTP headers to communicate deprecation timelines to consumers:
// Step 1: Mark endpoint as deprecated (still fully functional)
router.get('/users', (req, res) => {
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 01 Mar 2027 00:00:00 GMT');
res.set('Link', '</api/v2/users>; rel="successor-version"');
res.json(formatV1(users));
});
// Step 2: Log usage of deprecated endpoints to track migration
router.get('/users', (req, res) => {
deprecationLogger.record({ endpoint: '/api/v1/users', client: req.headers['user-agent'] });
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 01 Mar 2027 00:00:00 GMT');
res.json(formatV1(users));
});
// Step 3: After sunset date, return 410 Gone
router.get('/users', (req, res) => {
res.status(410).json({
error: {
code: 'ENDPOINT_DEPRECATED',
message: 'This endpoint has been removed. Migrate to /api/v2/users.',
migrationGuide: 'https://docs.api.com/migration/v1-to-v2'
}
});
});
Changelog Format
Maintain a structured changelog so API consumers can track changes:
# API Changelog
## [2.0.0] - 2026-03-01
### Breaking Changes
- Changed user response: `name` field split into `firstName` and `lastName`
- Authentication required on all endpoints (previously public)
### Migration Guide
- See https://docs.api.com/migration/v1-to-v2
- v1 endpoints remain available until 2027-03-01
## [1.2.0] - 2026-01-15
### Added
- `avatarUrl` field on User resource
- `sort` query parameter on GET /users
### Deprecated
- None
OpenAPI Documentation for Versioned APIs
OpenAPI Documentation for Versioned APIs
Multi-Version OpenAPI Specification
Document both versions side by side so consumers understand the differences:
# openapi.yaml
openapi: 3.0.3
info:
title: E-Commerce API
version: 2.0.0
description: |
## API Versioning
- **v1** (deprecated): Original release. Sunset date: 2027-03-01
- **v2** (current): New response format with envelope pattern
All new integrations must use v2. Migration guide: https://docs.api.com/migration/v1-to-v2
contact:
name: API Support
email: api-support@example.com
servers:
- url: https://api.example.com/api/v1
description: v1 (deprecated)
- url: https://api.example.com/api/v2
description: v2 (current)
paths:
/users:
get:
operationId: listUsers
summary: List all users
tags: [Users]
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: limit
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
'200':
description: Paginated user list
content:
application/json:
schema:
$ref: '#/components/schemas/UserListResponse'
components:
schemas:
UserListResponse:
type: object
required: [data, meta]
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
meta:
$ref: '#/components/schemas/PaginationMeta'
User:
type: object
required: [id, name, email]
properties:
id:
type: string
example: usr_abc123
name:
type: string
example: Alice Johnson
email:
type: string
format: email
avatarUrl:
type: string
format: uri
nullable: true
createdAt:
type: string
format: date-time
PaginationMeta:
type: object
properties:
page:
type: integer
limit:
type: integer
total:
type: integer
totalPages:
type: integer
ErrorResponse:
type: object
properties:
error:
type: object
properties:
code:
type: string
example: VALIDATION_ERROR
message:
type: string
example: Invalid request parameters
details:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string
Automated Version Diff in CI
Generate a diff report on every PR to catch accidental breaking changes:
# .github/workflows/api-diff.yml
name: API Breaking Changes
on:
pull_request:
paths: ['openapi.yaml']
jobs:
breaking-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install openapi-diff
run: npm install -g openapi-diff
- name: Compare with main branch
run: |
git fetch origin main
git show origin/main:openapi.yaml > /tmp/openapi-main.yaml
openapi-diff /tmp/openapi-main.yaml openapi.yaml \
--fail-on-incompatible \
--output /tmp/api-diff-report.md
- name: Comment PR with diff
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const diff = fs.readFileSync('/tmp/api-diff-report.md', 'utf8');
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `## API Breaking Changes Report\n\n${diff}`
});
Version Header Middleware
Always include version metadata in responses so consumers can debug:
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) => {
res.set('X-API-Version', req.apiVersion || 'v2');
res.set('X-API-Deprecated', req.isDeprecated ? 'true' : 'false');
if (req.sunsetDate) res.set('Sunset', req.sunsetDate);
return originalJson(body);
};
next();
});
OpenAPI Validation Middleware
Enforce the spec at runtime to catch drift between docs and implementation:
import swaggerValidation from 'express-openapi-validator';
app.use(
swaggerValidation.middleware({
apiSpec: './openapi.yaml
validateRequests: true,
validateResponses: process.env.NODE_ENV !== 'production',
ignorePaths: /^\/api\/v1\//, // Skip validation for deprecated v1
})
);
Quiz
1. Your public API is consumed by 50 third-party mobile apps. You need to change the user response format. Which versioning strategy should you use?
2. You add an optional `phone` field to the User response in v1. Do you need a v2 release?
3. What HTTP headers should you include when deprecating an API endpoint?
Flashcards
Question
What is the difference between a breaking and non-breaking API change?
Click to reveal answer
Answer
A breaking change alters the contract in a way that causes existing clients to fail — removing a field, renaming a property, changing a field's type, narrowing allowed values, or adding mandatory authentication. A non-breaking change is additive: new optional fields, new optional query parameters, or new endpoints. Non-breaking changes can ship in the current version; breaking changes require a new version with a deprecation period.
Question
What does the HTTP Sunset header do?
Click to reveal answer
Answer
The Sunset header (RFC 8594) tells clients when an endpoint or API version will be removed. It contains an HTTP-date value (e.g., Sunset: Sat, 01 Mar 2027 00:00:00 GMT). Clients can use this to automate migration alerts, build countdown timers, or trigger automated upgrades before the deadline. After the sunset date, the server typically returns 410 Gone.
Question
Why is URL path versioning preferred for public APIs?
Click to reveal answer
Answer
URL path versioning (/api/v1/users) is explicit, easily cacheable by CDNs (each version is a distinct URL), simple to test in a browser, and universally understood by API consumers. It requires no special client configuration — any HTTP client can target a version by changing the URL. The main criticism (REST purists say URLs should identify resources, not versions) is outweighed by practical benefits for third-party consumption.
Revision Notes
Key Takeaways
- 1. URL path versioning is the safest choice for public APIs — it is explicit, cacheable, and easy to debug
- 2. Adding optional fields, optional query parameters, and new endpoints are non-breaking changes that do not require a new version
- 3. Removing fields, renaming properties, changing types, and narrowing allowed values are breaking changes that require a new version
- 4. Use Deprecation, Sunset, and Link headers to give clients a machine-readable deprecation timeline
- 5. OpenAPI specs should document all active versions and mark deprecated endpoints with the deprecated: true flag
- 6. Maintain a structured changelog so consumers can track what changed in each release
- 7. Always include X-API-Version and X-API-Deprecated headers in responses for debugging
Interview Tips
- • Explain the three versioning strategies and when you would choose each one
- • Give examples of breaking vs non-breaking changes and how you decide when to bump a version
- • Describe a deprecation workflow: how you communicate, track usage, and enforce sunset dates
- • Explain how you would structure an OpenAPI spec to document multiple API versions
- • Discuss how to detect accidental breaking changes in CI using OpenAPI diff tools
Cheat Sheet
API Versioning Cheat Sheet
Versioning Strategies:
- URL path:
/api/v1/users— best for public APIs - Header:
Accept: application/vnd.api.v2+json— clean URLs, internal APIs - Query param:
?version=2— simplest but pollutes URLs
Breaking Changes (require new version):
- Remove or rename a field
- Change field type (number → string)
- Narrow allowed values
- Add required authentication
- Change error response structure
Non-Breaking Changes (safe in current version):
- Add optional field to response
- Add optional query parameter
- Add new endpoint
Deprecation Headers:
Deprecation: trueSunset: <HTTP-date>Link: </api/v2/users>; rel="successor-version"
OpenAPI Best Practices:
- Document all versions in the same spec or separate files per version
- Use
deprecated: trueon removed endpoints - Run openapi-diff in CI to catch breaking changes on PRs
- Use express-openapi-validator to enforce the spec at runtime
After Sunset:
- Return
410 Gonewith a migration guide link - Log remaining hits to identify slow adopters
- Keep the endpoint stub for at least 30 days after sunset