API Versioning Strategies: URL, Header, and Query Param
Compare API versioning approaches: URL path, custom headers, query parameters, and content negotiation. Includes migration strategies and real-world tradeoffs.
What you'll learn
- ✓The four main API versioning strategies and when to use each
- ✓How to implement versioning in practice with code examples
- ✓How to plan version deprecation and migration
Prerequisites
- •REST API design basics
- •HTTP methods and status codes
- •Basic backend development experience
Every API eventually needs to change in ways that break existing clients. Versioning gives you a way to evolve your API without forcing all consumers to update simultaneously. There is no universally correct approach. Each strategy has tradeoffs around discoverability, caching, routing complexity, and client burden.
Strategy 1: URL path versioning
The version number is embedded directly in the URL path.
GET /api/v1/users/42
GET /api/v2/users/42
This is the most common approach, used by GitHub, Stripe, and Twitter/X.
Implementation (Express.js)
import express from 'express';
const app = express();
// v1 routes
const v1Router = express.Router();
v1Router.get('/users/:id', (req, res) => {
// v1 returns flat structure
res.json({
id: 42,
name: 'Alice Smith',
email: 'alice@example.com'
});
});
// v2 routes
const v2Router = express.Router();
v2Router.get('/users/:id', (req, res) => {
// v2 returns nested structure with more fields
res.json({
id: 42,
name: { first: 'Alice', last: 'Smith' },
email: 'alice@example.com',
createdAt: '2026-01-15T10:30:00Z',
metadata: { loginCount: 47 }
});
});
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
Implementation (Spring Boot)
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
@GetMapping("/{id}")
public UserV1Dto getUser(@PathVariable Long id) {
return userService.getUserV1(id);
}
}
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
@GetMapping("/{id}")
public UserV2Dto getUser(@PathVariable Long id) {
return userService.getUserV2(id);
}
}
Pros: Easy to understand. URLs are bookmarkable and shareable. CDN and proxy caching works out of the box. Clear routing at the infrastructure level.
Cons: URL space pollution. Clients must update URLs to migrate. Implies the entire API changed when often only a few endpoints differ between versions.
Strategy 2: custom header versioning
The version is specified in a custom request header.
GET /api/users/42
X-API-Version: 2
Implementation
function versionMiddleware(req, res, next) {
const version = parseInt(req.headers['x-api-version'] || '1', 10);
req.apiVersion = version;
next();
}
app.use(versionMiddleware);
app.get('/api/users/:id', (req, res) => {
const user = userService.getUser(req.params.id);
if (req.apiVersion === 1) {
res.json({ id: user.id, name: user.fullName, email: user.email });
} else if (req.apiVersion === 2) {
res.json({
id: user.id,
name: { first: user.firstName, last: user.lastName },
email: user.email,
createdAt: user.createdAt
});
} else {
res.status(400).json({ error: `Unsupported API version: ${req.apiVersion}` });
}
});
Pros: Clean URLs. Easy to default to the latest version. The resource identity (URL) stays the same across versions.
Cons: Not visible in browser address bars. Harder to test with curl without remembering the header. Some proxies strip custom headers. Documentation must clearly communicate the header requirement.
Strategy 3: query parameter versioning
The version is a query parameter.
GET /api/users/42?version=2
Implementation
app.get('/api/users/:id', (req, res) => {
const version = parseInt(req.query.version || '1', 10);
const user = userService.getUser(req.params.id);
switch (version) {
case 1:
return res.json(formatV1(user));
case 2:
return res.json(formatV2(user));
default:
return res.status(400).json({
error: `Unsupported version: ${version}`,
supportedVersions: [1, 2]
});
}
});
Pros: Easy to add to existing APIs. Visible in URLs. Easy to test in browsers. Optional parameter with a default version.
Cons: Query parameters are typically used for filtering, not resource representation. Can conflict with caching strategies. Makes the version feel like an afterthought.
Strategy 4: content negotiation (Accept header)
The version is specified through the Accept header using vendor media types.
GET /api/users/42
Accept: application/vnd.myapi.v2+json
GitHub uses this approach.
Implementation
app.get('/api/users/:id', (req, res) => {
const accept = req.headers.accept || '';
let version = 1; // default
const match = accept.match(/application\/vnd\.myapi\.v(\d+)\+json/);
if (match) {
version = parseInt(match[1], 10);
}
const user = userService.getUser(req.params.id);
switch (version) {
case 1:
res.type('application/vnd.myapi.v1+json');
return res.json(formatV1(user));
case 2:
res.type('application/vnd.myapi.v2+json');
return res.json(formatV2(user));
default:
return res.status(406).json({ error: 'Not Acceptable' });
}
});
Pros: REST-purist approach. Keeps URLs clean. Uses HTTP semantics correctly. Allows fine-grained versioning per resource type.
Cons: Complex for clients. Difficult to test in browsers. Many developers are unfamiliar with vendor media types. Some HTTP clients make it awkward to set the Accept header.
Comparison table
| Criterion | URL Path | Header | Query Param | Content Negotiation |
|---|---|---|---|---|
| Discoverability | High | Low | Medium | Low |
| Caching | Simple | Needs Vary | Needs Vary | Needs Vary |
| Client simplicity | High | Medium | High | Low |
| URL cleanliness | Low | High | Medium | High |
| Routing complexity | Low | Medium | Low | Medium |
| REST compliance | Low | Medium | Low | High |
Version deprecation and sunset
Regardless of which strategy you choose, plan for deprecation:
// add deprecation headers to sunset versions
function deprecationMiddleware(req, res, next) {
if (req.apiVersion === 1) {
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT');
res.set('Link', '</api/v2/docs>; rel="successor-version"');
}
next();
}
The Sunset header (RFC 8594) tells clients when a version will be removed. The Deprecation header signals the version is no longer recommended.
Deprecation timeline
- Announce: Document the deprecation date at least 6 months in advance.
- Warn: Add deprecation headers to all responses from the old version.
- Monitor: Track usage of the deprecated version. Reach out to high-volume consumers.
- Return 410 Gone: After the sunset date, return
410 Gonewith a message pointing to the new version.
Practical recommendations
- Start with URL path versioning unless you have a strong reason not to. It is the simplest for both API developers and consumers.
- Version at the API level, not the endpoint level. Mixing versions per endpoint creates confusion.
- Only increment major versions for breaking changes. Additive changes (new fields, new endpoints) do not require a new version.
- Keep at most two versions active. Supporting three or more becomes a maintenance burden.
- Default to the latest stable version for new clients. Never default to a deprecated version.
The best versioning strategy is the one your team can maintain and your consumers can adopt without friction.
Related articles
- Backend Rate Limiting Implementation: Algorithms and Tradeoffs
Implement rate limiting with token bucket, sliding window, and fixed window algorithms. Covers Redis-backed solutions, response headers, and distributed setups.
- Backend Idempotency Keys Explained: Safe Retries Made Simple
Learn how idempotency keys make APIs safe to retry, with patterns for storage, expiry, conflict handling, and common pitfalls.
- REST APIs REST API Design Best Practices: A Practical Guide
Apply REST design best practices for resources, naming, status codes, pagination, and versioning to build clean, durable APIs.
- REST APIs REST API Versioning Strategies
Compare URL, header, and content-type versioning for REST APIs. Learn when to bump versions and how to retire old ones without breaking clients.