You've shipped v1 of your API. Clients are using it in production. Now you need to add a feature, fix a design mistake, or change how something works. How do you do it without breaking everyone's code?
This guide covers what breaks backwards compatibility, how to make safe additive changes, how to deprecate fields properly, and how to manage multiple API versions when you can't avoid breaking changes.
What Breaks Backwards Compatibility?
A breaking change is anything that requires clients to modify their code to keep working. Here's what counts:
Removing Fields
// v1 - clients depend on this structure
{
"id": 1,
"name": "Fluffy",
"status": "available",
"category": "cat" // Clients are reading this
}
// v2 - BREAKING: removed category
{
"id": 1,
"name": "Fluffy",
"status": "available"
// category is gone - client code breaks
}
Any client that accesses pet.category will now fail.
Renaming Fields
// v1
{ "petId": 1, "name": "Fluffy" }
// v2 - BREAKING: renamed petId to id
{ "id": 1, "name": "Fluffy" }
Even though the data is still there, clients looking for petId won't find it.
Changing Field Types
// v1 - quantity is a number
{ "orderId": 1, "quantity": 5 }
// v2 - BREAKING: quantity is now a string
{ "orderId": 1, "quantity": "5" }
Clients doing math with order.quantity will break.
Making Required Fields Optional (Sometimes)
This one is tricky. Making a required field optional is usually safe for reads, but can break writes:
// v1 - name is required
POST /pets
{ "name": "Fluffy", "status": "available" }
// v2 - name is now optional
POST /pets
{ "status": "available" } // This now succeeds
// But clients that assume every pet has a name will break:
const petName = pet.name.toUpperCase(); // TypeError: Cannot read property 'toUpperCase' of undefined
Making Optional Fields Required
// v1 - category is optional
POST /pets
{ "name": "Fluffy" } // This works
// v2 - BREAKING: category is now required
POST /pets
{ "name": "Fluffy" } // 400 Bad Request: category is required
Existing client code that doesn't send category will start failing.
Changing Validation Rules
// v1 - name can be 1-100 characters
POST /pets
{ "name": "A" } // This works
// v2 - BREAKING: name must be 3-100 characters
POST /pets
{ "name": "A" } // 400 Bad Request: name too short
Removing or Renaming Endpoints
// v1
GET /pets/:id
// v2 - BREAKING: renamed to /animals/:id
GET /animals/:id
Changing HTTP Status Codes
// v1 - returns 404 when pet not found
GET /pets/999
// 404 Not Found
// v2 - BREAKING: returns 400 instead
GET /pets/999
// 400 Bad Request
Clients that check if (response.status === 404) will break.
Changing Error Response Format
// v1
{
"error": "Pet not found"
}
// v2 - BREAKING: changed structure
{
"errors": [
{ "code": "NOT_FOUND", "message": "Pet not found" }
]
}
What Doesn't Break Compatibility?
These changes are safe:
Adding New Fields
// v1
{ "id": 1, "name": "Fluffy" }
// v2 - SAFE: added new field
{ "id": 1, "name": "Fluffy", "breed": "Persian" }
Clients that don't know about breed will ignore it. This is why you should always ignore unknown fields when parsing JSON.
Adding New Endpoints
// v1
GET /pets
POST /pets
// v2 - SAFE: added new endpoint
GET /pets
POST /pets
GET /pets/:id/photos // New endpoint
Adding New Optional Parameters
// v1
GET /pets?status=available
// v2 - SAFE: added optional filter parameter
GET /pets?status=available&category=cat
As long as the parameter is optional and has a sensible default, existing clients keep working.
Making Required Fields Optional (For Writes)
// v1 - shipDate is required
POST /orders
{ "petId": 1, "quantity": 1, "shipDate": "2024-03-15" }
// v2 - SAFE: shipDate is now optional (defaults to today)
POST /orders
{ "petId": 1, "quantity": 1 } // shipDate defaults to today
Existing clients that send shipDate keep working.
Relaxing Validation
// v1 - name must be 3-50 characters
POST /pets
{ "name": "Fluffy" }
// v2 - SAFE: name can now be 1-100 characters
POST /pets
{ "name": "A" } // Now valid
Existing valid requests remain valid.
The Additive-Only Strategy
The safest way to evolve an API is to only make additive changes:
- Add new fields instead of changing existing ones
- Add new endpoints instead of changing existing ones
- Add new optional parameters instead of changing required ones
- Relax validation instead of tightening it
Example: You want to split the name field into firstName and lastName.
Bad (breaking):
// v1
{ "id": 1, "name": "Fluffy Cat" }
// v2 - BREAKING
{ "id": 1, "firstName": "Fluffy", "lastName": "Cat" }
Good (additive):
// v1
{ "id": 1, "name": "Fluffy Cat" }
// v2 - SAFE: keep name, add new fields
{
"id": 1,
"name": "Fluffy Cat", // Keep for backwards compatibility
"firstName": "Fluffy",
"lastName": "Cat"
}
New clients can use firstName and lastName. Old clients keep using name.
Field Deprecation
When you need to eventually remove a field, deprecate it first:
Step 1: Mark as Deprecated
Add a deprecation notice to your API docs and include a Deprecated header:
app.get('/pets/:id', (req, res) => {
const pet = await getPet(req.params.id);
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 31 Dec 2024 23:59:59 GMT');
res.json(pet);
});
Step 2: Add Deprecation Warnings
Log when clients use deprecated fields:
app.get('/pets/:id', (req, res) => {
const pet = await getPet(req.params.id);
// Log usage of deprecated endpoint
logger.warn('Client used deprecated /pets/:id endpoint', {
clientId: req.headers['x-client-id'],
userAgent: req.headers['user-agent']
});
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 31 Dec 2024 23:59:59 GMT');
res.json(pet);
});
Step 3: Contact Clients
If you know who your API clients are, email them:
Subject: Action Required: /pets/:id endpoint will be removed on Dec 31, 2024
We're deprecating the /pets/:id endpoint in favor of /animals/:id.
What you need to do: - Update your code to use /animals/:id instead of /pets/:id - The new endpoint returns the same data structure - You have until Dec 31, 2024 to migrate
Need help? Reply to this email.
Step 4: Remove After Sunset Date
After the sunset date, remove the deprecated field or endpoint:
app.get('/pets/:id', (req, res) => {
res.status(410).json({
error: 'This endpoint has been removed. Use /animals/:id instead.',
sunsetDate: '2024-12-31',
migrationGuide: 'https://docs.example.com/migration-guide'
});
});
The 410 Gone status code indicates the resource is permanently gone.
Sunset Headers
The Sunset header (RFC 8594) tells clients when an endpoint will be removed:
GET /pets/1
HTTP/1.1 200 OK
Deprecation: true
Sunset: Sat, 31 Dec 2024 23:59:59 GMT
Link: <https://docs.example.com/migration>; rel="sunset"
{
"id": 1,
"name": "Fluffy"
}
Clients can parse this header and warn developers:
async function fetchPet(id) {
const res = await fetch(`https://petstore.example.com/pets/${id}`);
const deprecated = res.headers.get('Deprecation');
const sunset = res.headers.get('Sunset');
if (deprecated && sunset) {
console.warn(`Warning: /pets/${id} is deprecated and will be removed on ${sunset}`);
}
return res.json();
}
API Versioning Strategies
When you can't avoid breaking changes, you need versioning.
URL Versioning
Put the version in the URL:
GET /v1/pets/1
GET /v2/pets/1
Pros:- Simple and explicit - Easy to route to different codebases - Works with all HTTP clients
Cons:- URLs change, breaking bookmarks and links - Clients need to update every endpoint URL
Implementation:
const express = require('express');
const app = express();
// v1 routes
app.get('/v1/pets/:id', async (req, res) => {
const pet = await getPet(req.params.id);
res.json({
petId: pet.id, // v1 uses petId
name: pet.name
});
});
// v2 routes
app.get('/v2/pets/:id', async (req, res) => {
const pet = await getPet(req.params.id);
res.json({
id: pet.id, // v2 uses id
name: pet.name,
breed: pet.breed // v2 adds breed
});
});
Header Versioning
Put the version in a header:
GET /pets/1
Accept: application/vnd.petstore.v2+json
Pros:- URLs stay the same - More "RESTful" (debatable)
Cons:- Harder to test (can't just paste URL in browser) - Requires custom header support
Implementation:
app.get('/pets/:id', async (req, res) => {
const accept = req.headers['accept'];
const pet = await getPet(req.params.id);
if (accept.includes('v2')) {
return res.json({
id: pet.id,
name: pet.name,
breed: pet.breed
});
}
// Default to v1
res.json({
petId: pet.id,
name: pet.name
});
});
Query Parameter Versioning
GET /pets/1?version=2
Pros:- Easy to test - URLs stay mostly the same
Cons:- Mixes versioning with query parameters - Can be forgotten in client code
Content Negotiation
Use the Accept header with custom media types:
GET /pets/1
Accept: application/vnd.petstore+json; version=2
This is the most "correct" REST approach, but also the most complex.
Maintaining Multiple Versions
Shared Business Logic
Don't duplicate your business logic across versions. Keep it in a shared layer:
// Shared business logic
class PetService {
async getPet(id) {
return db.query('SELECT * FROM pets WHERE id = ?', [id]);
}
}
// v1 serializer
function serializePetV1(pet) {
return {
petId: pet.id,
name: pet.name,
status: pet.status
};
}
// v2 serializer
function serializePetV2(pet) {
return {
id: pet.id,
name: pet.name,
status: pet.status,
breed: pet.breed,
category: pet.category
};
}
// v1 endpoint
app.get('/v1/pets/:id', async (req, res) => {
const pet = await petService.getPet(req.params.id);
res.json(serializePetV1(pet));
});
// v2 endpoint
app.get('/v2/pets/:id', async (req, res) => {
const pet = await petService.getPet(req.params.id);
res.json(serializePetV2(pet));
});
Version Sunset Policy
Don't maintain old versions forever. Set a policy:
- Support the current version and one previous version
- Give clients 12 months to migrate
- Announce deprecations 6 months in advance
Example timeline: - Jan 2024: Release v2, deprecate v1 - Jul 2024: Send migration reminders - Jan 2025: Remove v1
Migration Guides
When you release a breaking change, write a migration guide:
# Migrating from v1 to v2
## Breaking Changes
### 1. `petId` renamed to `id`
**v1:**
```json
{ "petId": 1, "name": "Fluffy" }
v2:
{ "id": 1, "name": "Fluffy" }
Migration:
// Before
const id = pet.petId;
// After
const id = pet.id;
2. category field removed
The category field has been replaced with a category object:
v1:
{ "id": 1, "category": "cat" }
v2:
{
"id": 1,
"category": {
"id": 1,
"name": "cat"
}
}
Migration:
// Before
const category = pet.category;
// After
const category = pet.category.name;
Testing
Update your tests:
// Before
expect(pet).toHaveProperty('petId');
// After
expect(pet).toHaveProperty('id');
Timeline
- v1 will be supported until Jan 31, 2025
- Update your code by Dec 31, 2024
- Contact support@example.com if you need help
## PetStore API Example: Evolving the Order Model
Let's say you want to change how orders work in the PetStore API.
**v1:**
```json
{
"orderId": 1,
"petId": 42,
"quantity": 1,
"shipDate": "2024-03-15",
"status": "placed"
}
v2 (breaking changes):- Rename orderId to id- Replace petId with a full pet object - Add customer object - Change status to an enum
Additive approach (better):
{
"orderId": 1, // Keep for backwards compatibility
"id": 1, // Add new field
"petId": 42, // Keep for backwards compatibility
"pet": { // Add new field
"id": 42,
"name": "Fluffy"
},
"quantity": 1,
"shipDate": "2024-03-15",
"status": "placed",
"customer": { // Add new field
"id": 100,
"name": "John Doe"
}
}
This way, v1 clients keep working, and v2 clients get the richer data.
Summary
Backwards compatibility is about respecting your clients' time:
- Avoid breaking changes when possible
- Use additive changes (new fields, new endpoints)
- Deprecate before removing (with sunset headers)
- Version your API when you must break compatibility
- Maintain multiple versions for a transition period
- Write clear migration guides
- Give clients plenty of notice (6-12 months)
The best API change is one your clients don't have to think about.