Breaking changes
Which changes break clients and which don't, the rule behind them, and a diff that finds them.
APIs have to change: new features, fixed mistakes, better designs. Some changes are invisible to existing clients and some break them. A breaking change is one that makes a client that worked yesterday fail today, without the client changing anything. Telling the two apart, before the change ships, is the most valuable thing a contract can do for a team. This lesson gives the rule, the common cases, and a diff that compares two versions of a spec.
The rule
Every case below follows from one idea, which is easiest to remember in two halves:
- A response may give clients more than before, but not less. Clients read responses. Anything they read must still be there, with the same type and meaning.
- A request may ask clients for less than before, but not more. Clients write requests. Anything they send today must still be accepted, and nothing new can be demanded of them.
| Change | Safe? | Why |
|---|---|---|
| Add a new endpoint | Safe | No existing client calls it |
| Add a field to a response | Safe, mostly | Clients that don't know it ignore it (but see below) |
| Add an optional field to a request | Safe | Clients that don't send it carry on |
| Remove or rename a response field | Breaking | Clients that read it get nothing; a rename is a removal plus an addition |
| Change a field's type | Breaking | A number that becomes a string breaks arithmetic, sorting and parsing |
| Make a response field nullable, or optional | Breaking | Clients that assumed a value crash on null or a missing key |
| Make a request field required | Breaking | Clients that never sent it are now refused |
| Add a value to a response enum | Breaking | A client with a switch over the old values meets one it can't handle |
| Remove a value from a request enum | Breaking | Clients sending that value are refused |
| Remove an endpoint or a status code clients handle | Breaking | Clients calling it, or expecting it, fail |
| Change an operationId | Breaking for generated clients | Their method names come from it |
The catch in "adding a field is safe"
Adding a response field is only safe if clients ignore fields they don't know. A client that validates responses against a strict copy of the old schema, with additionalProperties: false, rejects the new field. The usual advice, sometimes called the tolerant reader, is that clients should accept unknown fields, while the provider's own spec stays strict and is updated along with the API. Contract tests then check the API against the current spec, not an old copy.
Finding breaking changes automatically
Reading two versions of a spec by eye doesn't scale; this API's document is nearly half a megabyte. src/diff.mjs walks both versions and applies the rule:
// Compares two versions of an OpenAPI document and lists the changes that would break existing clients.
//
// The rule behind every check: a response may give clients more than before, but not less; a request may ask
// clients for less than before, but not more.
import { resolveRef } from "./openapi.mjs";
const METHODS = ["get", "put", "post", "delete", "patch", "head", "options"];
export function breakingChanges(before, after) {
const problems = [];
for (const [path, item] of Object.entries(before.paths ?? {})) {
for (const method of METHODS) {
const old = item[method];
if (!old) continue;
const now = after.paths?.[path]?.[method];
const op = `${method.toUpperCase()} ${path}`;
if (!now) {
problems.push(`${op}: operation removed`);
continue;
}
// Requests: anything clients now have to send, that they didn't before, breaks the ones that don't send it.
const oldParams = new Set((old.parameters ?? []).map((p) => `${p.in}:${p.name}`));
for (const p of now.parameters ?? []) {
if (p.required && !oldParams.has(`${p.in}:${p.name}`)) problems.push(`${op}: new required ${p.in} parameter "${p.name}"`);
}
if (now.requestBody?.required && !old.requestBody?.required) problems.push(`${op}: a request body is now required`);
const oldBody = jsonSchema(old.requestBody);
const newBody = jsonSchema(now.requestBody);
if (oldBody && newBody) compare(oldBody, newBody, before, after, `${op} request body`, "request", problems);
// Responses: anything clients relied on receiving, that they might no longer receive.
for (const [status, response] of Object.entries(old.responses ?? {})) {
if (!status.startsWith("2")) continue;
const newResponse = now.responses?.[status];
if (!newResponse) {
problems.push(`${op}: response ${status} removed`);
continue;
}
const oldSchema = jsonSchema(response);
const newSchema = jsonSchema(newResponse);
if (oldSchema && newSchema) compare(oldSchema, newSchema, before, after, `${op} ${status} response`, "response", problems);
}
}
}
return problems;
}
function jsonSchema(part) {
return part?.content?.["application/json"]?.schema;
}
function compare(oldSchema, newSchema, before, after, at, direction, problems, seen = new Set()) {
const key = `${oldSchema.$ref ?? ""}|${newSchema.$ref ?? ""}|${at}`;
if (seen.has(key)) return;
seen.add(key);
const o = oldSchema.$ref ? resolveRef(before, oldSchema.$ref) : oldSchema;
const n = newSchema.$ref ? resolveRef(after, newSchema.$ref) : newSchema;
if (o.type && n.type && o.type !== n.type) {
problems.push(`${at}: type changed from ${o.type} to ${n.type}`);
return;
}
if (direction === "response" && !o.nullable && n.nullable) problems.push(`${at}: can now be null`);
if (direction === "request" && o.nullable && !n.nullable) problems.push(`${at}: no longer accepts null`);
if (o.enum && n.enum) {
// A client sending a value that's gone is refused; a client receiving a value it never heard of may break.
const lost = o.enum.filter((v) => !n.enum.includes(v));
const gained = n.enum.filter((v) => !o.enum.includes(v));
if (direction === "request" && lost.length) problems.push(`${at}: no longer accepts ${lost.map((v) => JSON.stringify(v)).join(", ")}`);
if (direction === "response" && gained.length) problems.push(`${at}: can now be ${gained.map((v) => JSON.stringify(v)).join(", ")}`);
}
if (o.items && n.items) compare(o.items, n.items, before, after, `${at}[]`, direction, problems, seen);
const oldProps = o.properties ?? {};
const newProps = n.properties ?? {};
const oldRequired = new Set(o.required ?? []);
const newRequired = new Set(n.required ?? []);
if (direction === "response") {
for (const name of Object.keys(oldProps)) {
if (!(name in newProps)) problems.push(`${at}: property "${name}" removed`);
else {
if (oldRequired.has(name) && !newRequired.has(name)) problems.push(`${at}: property "${name}" is no longer always present`);
compare(oldProps[name], newProps[name], before, after, `${at}.${name}`, direction, problems, seen);
}
}
} else {
for (const name of newRequired) if (!oldRequired.has(name)) problems.push(`${at}: property "${name}" is now required`);
for (const name of Object.keys(oldProps)) {
if (name in newProps) compare(oldProps[name], newProps[name], before, after, `${at}.${name}`, direction, problems, seen);
else if (n.additionalProperties === false) problems.push(`${at}: property "${name}" removed, so clients that send it are refused`);
}
}
}- It goes through every operation in the oldspec. An operation that is gone is breaking; one that is new isn't checked, because adding is safe.
- For requests, it looks for anything newly demanded: a required parameter, a required body, a required property.
- For each success response,
comparewalks both schemas together, following$ref, and reports anything clients could lose: a removed property, a changed type, a value that can now be null, a property no longer guaranteed. directionis what flips the rule. The same change, such as an enum gaining a value, is breaking in a response and harmless in a request.
Trying it
demos/breaking.mjs loads the real spec, makes four changes to a copy, and diffs the two. Two changes are safe, a new response field and a new endpoint, and two are breaking: price becomes a string, and todos start requiring a due date.
// Makes four changes to a copy of the real spec, two safe and two breaking, and prints what the diff finds.
// Run: node demos/breaking.mjs
import { breakingChanges } from "../src/diff.mjs";
import { loadSpec } from "../src/openapi.mjs";
const before = await loadSpec();
const after = structuredClone(before);
const product = after.components.schemas.ProductResponse;
product.properties.discount = { type: "number" }; // 1. a new response field: safe
after.paths["/api/Products/featured"] = structuredClone(after.paths["/api/Products/categories"]); // 2. a new operation: safe
product.properties.price = { type: "string" }; // 3. price becomes a string: breaking
after.components.schemas.Todo.required = ["title", "dueDate"]; // 4. todos now need a due date: breaking
const problems = breakingChanges(before, after);
console.log(`${problems.length} breaking change(s):`);
for (const problem of problems) console.log(` ${problem}`);$ node demos/breaking.mjs
9 breaking change(s):
GET /api/Products 200 response[].price: type changed from number to string
POST /api/Products 201 response.data.price: type changed from number to string
GET /api/Products/{id} 200 response.price: type changed from number to string
PUT /api/Products/{id} 200 response.data.price: type changed from number to string
GET /api/Products/category/{name} 200 response[].price: type changed from number to string
POST /api/Todos request body: property "title" is now required
POST /api/Todos request body: property "dueDate" is now required
PUT /api/Todos/{id} request body: property "title" is now required
PUT /api/Todos/{id} request body: property "dueDate" is now requiredThe two safe changes don't appear at all. The two breaking ones appear nine times, once for every place a client would feel them: priceis in five responses, and the todo body is used by both create and update. That's deliberate. Each line is a consumer that might break, which is what a reviewer needs to know.
The tests pin down each rule separately, for example:
test("a response field that can now be null breaks clients that don't expect null", () => {
const next = copy();
productResponse(next).properties.title = { type: "string", nullable: true };
assert.ok(breakingChanges(spec, next).includes("GET /api/Products/{id} 200 response.title: can now be null"));
});test("a newly required request field breaks clients that don't send it", () => {
const next = copy();
productRequest(next).required = ["title", "price"];
const problems = breakingChanges(spec, next);
assert.ok(problems.includes('POST /api/Products request body: property "title" is now required'));
assert.ok(problems.includes('POST /api/Products request body: property "price" is now required'));
});When a breaking change is needed
Sometimes the old design really has to go. The way to change it without breaking anyone is to make the change in stages, often called expand and contract:
- Expand: add the new field or endpoint alongside the old one. Nothing breaks.
- Migrate: tell consumers, and wait while they move to the new one.
- Contract: remove the old one once nobody uses it. The diff will flag it, and now that's expected.
For changes too big for that, APIs publish a new version and keep the old one running for a while. This API does it for its profile endpoint: version 1 has flat fields, version 2 reshapes them, and version 1 announces its retirement in standard headers:
$ curl -i https://api.testingapis.com/api/v1/profile
HTTP/1.1 200 OK
deprecation: @1751328000
sunset: Thu, 31 Dec 2026 23:59:59 GMT
link: </api/v2/profile>; rel="successor-version"
{"id":101,"name":"Ava Thompson","email":"ava.thompson@example.com","phone":"+1-555-0101","address":"742 Evergreen Terrace, Springfield, IL 62704, USA","memberSince":"2023-04-12"}
$ curl https://api.testingapis.com/api/v2/profile
{"id":101,"firstName":"Ava","lastName":"Thompson","displayName":"Ava T.","contact":{"email":"ava.thompson@example.com","phone":{"countryCode":"+1","number":"555-0101"}},"address":{"street":"742 Evergreen Terrace","city":"Springfield",...},...,"apiVersion":"2"}Deprecation (RFC 9745) says version 1 has been deprecated since the Unix time 1751328000, 1 July 2025. Sunset (RFC 8594) says when it stops working. Link with rel="successor-version" says where to go instead. A well-behaved client logs a warning when it sees these headers, so somebody notices long before the sunset date.
Check your understanding
1.What's the rule for responses, and the rule for requests?Show answer
Responses may give more, never less. Requests may ask for less, never more.
2.A field is renamed from inStock to available. Breaking or not?Show answer
Breaking. A rename removes inStock, which existing clients read.
3.An order status enum gains a new value, "on_hold". Why can that break clients?Show answer
Clients that handle each known status, for example in a switch statement, meet a value they have no case for.
4.Why did two changes produce nine lines in the diff?Show answer
Each schema is used by several operations, and the diff reports every operation where a client would feel the change.
5.What do the Deprecation and Sunset headers tell a client?Show answer
That the endpoint is deprecated, since when, and the date it will stop working.
Exercise
Add two rules to diff.mjs, each with a test: an operationId that changed should be reported, and so should a success response that no longer offers application/json as a content type. Then write a client-side check that warns when a response carries a Deprecation header, and try it on /api/v1/profilesign in to see.