Judging OpenAPI breaking changes
You cannot tell whether an OpenAPI change is breaking by looking at the diff alone. The same edit can be harmless in a request schema and breaking in a response schema. What matters is who produces the data, who consumes it, and how tolerant the consumers are. This note turns that into two rules, applies them with a small runnable checker, and then shows the same changes breaking a real client. Everything below was run on Node.js v24.7.0.
Two directions, two rules
- Requests are produced by clients and consumed by the server. A change is compatible if the new schema still accepts everything the old one accepted. The new schema may be more permissive, never less.
- Responses are produced by the server and consumed by clients that were written against the old schema. A change is compatible if the new schema never produces anything the old one could not. The new schema may be stricter, never looser.
Applying the rules to seven common edits gives this table. The enum and property rows follow the conventions in Google's AIP-180 (see references), which also forbids changing a field's type within a major version, even when the new type is wire-compatible.
| Change | In a request schema | In a response schema |
|---|---|---|
| Add an optional property | fine | fine |
| Add a required property | breaking | fine (more is guaranteed) |
| Remove a property | risk: breaks clients if the server rejects unknown properties | breaking (risk if it was optional) |
| Add an enum value | fine | risk: breaks clients that handle values exhaustively |
| Remove an enum value | breaking | fine |
integer → number | fine (accepts more) | breaking (clients may send or expect only integers) |
string → string or null | fine (accepts more) | breaking (clients may assume non-null) |
"Risk" means the contract alone cannot decide: it depends on how the clients are written. We come back to that below.
A direction-aware checker in about 50 lines
The following checker applies the two rules to a schema pair. It is a teaching example, not an OpenAPI tool: it handles only type, enum, properties, required, and items, and it does not resolve $ref, composition (allOf, oneOf), or additionalProperties. Nullability is expressed as a type array, as in JSON Schema 2020-12.
// A minimal, direction-aware schema comparison. Not a real OpenAPI tool:
// it handles only type, enum, properties, required and items (no $ref, allOf/oneOf, additionalProperties).
// Set of JSON types a schema accepts. In JSON Schema every integer is also a number.
const accepted = (s) => {
const t = new Set([].concat(s.type ?? []));
if (t.has("number")) t.add("integer");
return t;
};
const subset = (a, b) => [...a].every((x) => b.has(x));
function typeChange(oldS, newS) {
const a = accepted(oldS), b = accepted(newS);
if (!a.size || !b.size || (subset(a, b) && subset(b, a))) return null;
if (subset(a, b)) return "widened";
if (subset(b, a)) return "narrowed";
return "changed";
}
// direction "request": the server consumes what clients send -> the new schema must accept everything the old one did.
// direction "response": clients consume what the server sends -> the new schema must not produce anything the old one could not.
export function compare(oldS, newS, direction, path = "$") {
const out = [];
const add = (level, msg) => out.push(`${level.padEnd(8)} ${path}: ${msg}`);
const t = typeChange(oldS, newS);
const [from, to] = [[].concat(oldS.type), [].concat(newS.type)];
if (t === "changed" || (t === "narrowed" && direction === "request") || (t === "widened" && direction === "response"))
add("BREAKING", `type ${t} ${from} -> ${to}`);
if (oldS.enum && newS.enum) {
const removed = oldS.enum.filter((v) => !newS.enum.includes(v));
const added = newS.enum.filter((v) => !oldS.enum.includes(v));
if (direction === "request" && removed.length) add("BREAKING", `enum values removed: ${removed}`);
if (direction === "response" && added.length) add("RISK", `enum values added: ${added} (breaks clients that switch exhaustively)`);
}
const oldReq = oldS.required ?? [], newReq = newS.required ?? [];
for (const k of newReq.filter((k) => !oldReq.includes(k)))
if (direction === "request") add("BREAKING", `property "${k}" is now required`);
for (const k of oldReq.filter((k) => !newReq.includes(k)))
if (direction === "response" && newS.properties?.[k]) add("BREAKING", `property "${k}" is no longer guaranteed`);
for (const [k, oldProp] of Object.entries(oldS.properties ?? {})) {
const newProp = newS.properties?.[k];
if (!newProp) {
if (direction === "response") add(oldReq.includes(k) ? "BREAKING" : "RISK", `property "${k}" removed from the response`);
else add("RISK", `property "${k}" removed from the request (breaks clients if the server rejects unknown properties)`);
} else out.push(...compare(oldProp, newProp, direction, `${path}.${k}`));
}
if (oldS.items && newS.items) out.push(...compare(oldS.items, newS.items, direction, `${path}[]`));
return out;
}The demo applies the seven edits to an order schema (id and status required; status is an enum of pending and paid) and checks each edit once as a request schema and once as a response schema:
== request schema ==
add an optional property:
no finding
add a required property:
BREAKING $: property "currency" is now required
add an enum value:
no finding
remove an enum value:
BREAKING $.status: enum values removed: paid
integer -> number:
no finding
string -> string or null:
no finding
remove a required property:
RISK $: property "status" removed from the request (breaks clients if the server rejects unknown properties)
== response schema ==
add an optional property:
no finding
add a required property:
no finding
add an enum value:
RISK $.status: enum values added: refunded (breaks clients that switch exhaustively)
remove an enum value:
no finding
integer -> number:
BREAKING $.quantity: type widened integer -> number
string -> string or null:
BREAKING $.note: type widened string -> string,null
remove a required property:
BREAKING $: property "status" removed from the responseThe same edit gets a different verdict depending on direction: adding a required property is breaking only for requests, and widening integer to number is breaking only for responses.
What a "risk" does to a real client
Here is a client written against the old contract. It switches on status, calls note.trim(), and uses quantity as an integer. We feed it responses from each edit:
// A client written against the OLD contract: status is "pending" | "paid", quantity is an integer, note is a string.
function describe(order) {
switch (order.status) {
case "pending": return "Awaiting payment";
case "paid": return "Paid";
default: throw new Error(`unexpected status: ${order.status}`);
}
}
const attempt = (label, fn) => {
try {
console.log(`${label}: ${fn()}`);
} catch (e) {
console.log(`${label}: ${e.name}: ${e.message}`);
}
};
const before = { id: "o-1", status: "paid", quantity: 2, note: "gift" };
attempt("old response ", () => `${describe(before)}, note=${before.note.trim()}, rows=${new Array(before.quantity).fill("x").length}`);
attempt("enum value added ", () => describe({ ...before, status: "refunded" }));
attempt("string -> string|null ", () => ({ ...before, note: null }).note.trim());
attempt("integer -> number ", () => new Array(2.5).fill("x").length);
attempt("property removed ", () => describe({ id: "o-3", quantity: 1, note: "" }));old response : Paid, note=gift, rows=2
enum value added : Error: unexpected status: refunded
string -> string|null : TypeError: Cannot read properties of null (reading 'trim')
integer -> number : RangeError: Invalid array length
property removed : Error: unexpected status: undefinedEvery case is a response-side change, and every one failed in a different way: an exhaustive switch, a null dereference, an integer assumption, and a missing field. None of them is a syntax error in the contract, and a diff that only reports removed operations and new required request fields would not report any of them. Whether the added enum value is acceptable depends on whether the client was told to expect new values. AIP-180 says response enums may gain values but warns that existing code may not handle them gracefully.
What the checker does not model
- Strict validators. If a client or a gateway validates with
additionalProperties: false, adding a response property can break it. We did not test this. - References and composition. Real contracts use
$ref,allOf,oneOf, andanyOf. A tool must resolve them before it can compare anything. - Behavior outside the schema. Status codes, headers, authentication, ordering, rate limits, and semantics can break clients without any schema change.
- OpenAPI 3.0
nullable. OpenAPI 3.1's Schema Object is a superset of JSON Schema 2020-12 (spec §4.8.24), so we used type arrays. We did not handle the 3.0 keyword.
Make tolerance part of the contract
- Write down what clients must tolerate: unknown response properties, and unknown values of response enums. If you promise this, adding them is safe. If you do not, treat them as breaking.
- Review requests and responses separately in every contract change. Use the table above as the review checklist.
- Do not change a field's type inside a major version. Add a new field and deprecate the old one.
- Deprecate before removing, and keep populating fields that clients already read.
- Test the old client against the new server, not only the new spec against the old spec. The client demo above is the cheapest version of this.
Reproduce it
Save compat.mjs and client.mjs from above and the demo below in one folder, then run node demo.mjs and node client.mjs. Output is deterministic.
demo.mjs
import { compare } from "./compat.mjs";
const order = {
type: "object",
required: ["id", "status"],
properties: {
id: { type: "string" },
status: { type: "string", enum: ["pending", "paid"] },
quantity: { type: "integer" },
note: { type: "string" },
},
};
const clone = (o) => structuredClone(o);
const cases = {
"add an optional property": (s) => { s.properties.coupon = { type: "string" }; },
"add a required property": (s) => { s.properties.currency = { type: "string" }; s.required.push("currency"); },
"add an enum value": (s) => { s.properties.status.enum.push("refunded"); },
"remove an enum value": (s) => { s.properties.status.enum.pop(); },
"integer -> number": (s) => { s.properties.quantity.type = "number"; },
"string -> string or null": (s) => { s.properties.note.type = ["string", "null"]; },
"remove a required property": (s) => { delete s.properties.status; s.required = ["id"]; },
};
for (const direction of ["request", "response"]) {
console.log(`\n== ${direction} schema ==`);
for (const [name, change] of Object.entries(cases)) {
const next = clone(order);
change(next);
const findings = compare(order, next, direction, "$");
console.log(`${name}:\n ${findings.length ? findings.join("\n ") : "no finding"}`);
}
}What we did not verify
We ran the code only on Node.js v24.7.0. The checker is a minimal illustration and was not compared against any OpenAPI diff tool, so we make no claim about what a particular tool reports. We did not test strict-validation behavior, generated clients, code generators, $ref or composition handling, or OpenAPI 3.0-specific keywords. The compatibility rules are conventions (AIP-180 and common practice), not requirements of the OpenAPI Specification, which does not define "breaking".
References
- Google AIP-180: Backwards compatibility
- OpenAPI Specification v3.1.0 (§4.8.24 Schema Object)
- JSON Schema Validation, draft 2020-12 (
type,enum,required)
Related tools
- OpenAPI Breaking Change Checker — Flags removed operations and newly required request fields between two specs. Response-side changes like those above still need a manual review.
- OpenAPI Schema Diff — Compares named component schemas between two documents, which helps find the response changes discussed above.
Small Tools is built and operated by ZIV LABS. These links go to tools.ziv-labs.com.
Related notes
- JSON validation pitfalls — Duplicate keys, large integers, BOMs, and NaN: how JSON parsers in JavaScript and Python disagree, with commands you can run to reproduce each result.