Introduction
“How should we version our API?” is a question that sparks surprisingly heated debates. After building and maintaining APIs used by thousands of developers, I’ve learned that the best approach depends on your context—but some strategies are clearly better than others.
Why Version APIs?
APIs are contracts. When you change an API, you risk breaking clients that depend on the old behavior. Versioning lets you:
- Evolve your API without breaking existing clients
- Deprecate old functionality gracefully
- Support multiple client versions simultaneously
The Main Approaches
URL Path Versioning
GET /v1/users/123
GET /v2/users/123
Pros:
- Extremely clear and visible
- Easy to route at load balancer level
- Simple to implement
- Easy to document
Cons:
- Clutters URLs
- Can lead to code duplication
- Makes it tempting to create too many versions
When to use: Public APIs, APIs with many external consumers, when you want maximum clarity.
Query Parameter Versioning
GET /users/123?version=1
GET /users/123?api-version=2023-01-15
Pros:
- Keeps URLs clean
- Optional parameter (can default to latest)
- Easy to add to existing APIs
Cons:
- Easy to forget
- Can be cached incorrectly
- Less visible in logs and documentation
When to use: Internal APIs, when you want versioning to be optional.
Header Versioning
GET /users/123
Accept: application/vnd.myapi.v1+json
GET /users/123
X-API-Version: 2
Pros:
- Clean URLs
- Follows HTTP semantics (content negotiation)
- Separates versioning from resource identification
Cons:
- Hidden from casual inspection
- Harder to test in browser
- More complex client implementation
When to use: When you care about REST purity, sophisticated API consumers.
Date-Based Versioning
GET /users/123
Stripe-Version: 2023-10-16
Pros:
- Clear timeline of changes
- Encourages incremental evolution
- No arbitrary version numbers
Cons:
- Requires tracking what changed when
- Can be confusing (which date do I use?)
- Harder to communicate major changes
When to use: APIs that evolve frequently with small changes (Stripe’s approach).
My Recommendation
For most APIs, I recommend URL path versioning with a twist:
The Strategy
-
Use URL versioning for major versions only
/v1/users /v2/users # Only when breaking changes are unavoidable -
Evolve within versions using additive changes
- Add new fields (don’t remove old ones)
- Add new endpoints
- Add new optional parameters
-
Use feature flags for gradual rollouts
GET /v1/users/123?include=new_profile_fields
Why This Works
- Clarity: Developers immediately see which version they’re using
- Stability: Major versions are rare, so clients don’t need to update often
- Flexibility: Additive changes let you evolve without breaking anyone
Making Changes Without Breaking Clients
Safe Changes (No Version Bump)
// Adding a new optional field
interface User {
id: string;
name: string;
email: string;
avatar?: string; // New field, optional
}
// Adding a new endpoint
GET /v1/users/123/preferences // New endpoint
// Adding a new optional parameter
GET /v1/users?include_inactive=true // New parameter
Breaking Changes (Require Version Bump)
// Removing a field
// v1: { id, name, email }
// v2: { id, name } // email removed
// Changing field type
// v1: { age: "25" } // string
// v2: { age: 25 } // number
// Changing endpoint behavior
// v1: GET /users returns all users
// v2: GET /users returns paginated users
Implementing Versioning
Router-Level Versioning
// Express example
const v1Router = express.Router();
const v2Router = express.Router();
v1Router.get('/users/:id', v1UserController.get);
v2Router.get('/users/:id', v2UserController.get);
app.use('/v1', v1Router);
app.use('/v2', v2Router);
Controller-Level Versioning
class UserController {
async getUser(req: Request, res: Response) {
const version = this.getVersion(req);
const user = await this.userService.findById(req.params.id);
if (version === 1) {
return res.json(this.serializeV1(user));
} else {
return res.json(this.serializeV2(user));
}
}
private serializeV1(user: User) {
return {
id: user.id,
name: user.name,
email: user.email
};
}
private serializeV2(user: User) {
return {
id: user.id,
fullName: user.name, // Renamed field
emailAddress: user.email,
createdAt: user.createdAt // New field
};
}
}
Shared Logic, Different Serialization
The key is to share business logic while varying the API contract:
// Shared service layer
class UserService {
async findById(id: string): Promise<User> {
// Same logic for all versions
}
}
// Version-specific serializers
const serializers = {
v1: new UserSerializerV1(),
v2: new UserSerializerV2()
};
// Controller uses appropriate serializer
const serializer = serializers[version];
return res.json(serializer.serialize(user));
Deprecation Strategy
Communicate Early
HTTP/1.1 200 OK
Deprecation: Sun, 01 Jan 2025 00:00:00 GMT
Sunset: Sun, 01 Jul 2025 00:00:00 GMT
Link: </v2/users>; rel="successor-version"
Provide Migration Guides
Document exactly what changed and how to migrate:
## Migrating from v1 to v2
### User endpoint changes
| v1 | v2 | Notes |
|----|----|----|
| `name` | `fullName` | Renamed for clarity |
| `email` | `emailAddress` | Renamed for clarity |
| - | `createdAt` | New field |
### Code changes required
```diff
- const name = user.name;
+ const name = user.fullName;
### Monitor Usage
Track which versions are being used:
```typescript
app.use((req, res, next) => {
const version = extractVersion(req);
metrics.increment('api.requests', { version });
next();
});
Common Mistakes
Too Many Versions
If you have v1, v2, v3, v4, v5… you’re versioning too aggressively. Each version has maintenance cost.
Fix: Use additive changes within versions. Only bump for truly breaking changes.
Inconsistent Versioning
Different endpoints using different versioning schemes.
Fix: Pick one approach and stick with it across your entire API.
No Deprecation Period
Removing old versions without warning.
Fix: Announce deprecation at least 6-12 months in advance. Monitor usage before removal.
Versioning Internal APIs
Adding versioning overhead to APIs only used by your own team.
Fix: Internal APIs can often just evolve with coordinated deployments.
Real-World Example
Here’s how I structured versioning for a payment API:
/v1/payments # Original API (2020)
/v1/payments/intents # Added 2021, no version bump
/v1/refunds # Added 2021, no version bump
/v2/payments # Breaking changes (2023)
# - Changed amount from cents to decimal
# - Restructured error responses
# - Removed deprecated fields
Timeline:
- 2023-01: Announced v2, v1 deprecation
- 2023-06: v2 released, v1 still supported
- 2024-01: v1 sunset warning emails
- 2024-06: v1 removed
Conclusion
API versioning doesn’t have to be complicated:
- Use URL path versioning for clarity
- Evolve within versions using additive changes
- Only create new versions for breaking changes
- Deprecate gracefully with long notice periods
The goal is to give your API consumers stability while allowing your API to evolve. A well-versioned API builds trust and makes integration easier for everyone.