Maintaining API Backwards Compatibility

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,

TRY NANO BANANA FOR FREE

Maintaining API Backwards Compatibility

TRY NANO BANANA FOR FREE
Contents

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:

  1. Add new fields instead of changing existing ones
  2. Add new endpoints instead of changing existing ones
  3. Add new optional parameters instead of changing required ones
  4. 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.