A field that changed its type
An API that has always returned "total": 249.99 starts returning "total": "249.99". Same digits, now in quotes. Nothing in the backend tests fails, because the value is still correct.
The frontend is another story. In JavaScript, with order.total now a string:
order.total + 5; // "249.995", string concatenation, not addition
order.total * 2; // 499.98, still works, which hides the problem
order.total.toFixed(2); // TypeError: order.total.toFixed is not a function
Some code keeps working, some produces wrong numbers, and some crashes. That mix is what makes breaking changes expensive: they rarely fail in one obvious place.
This guide shows how to compare two versions of a response so changes like this are caught before release, by eye, in CI, or in the browser. Every comparison result below comes from running the code, not from writing it by hand.
What counts as a breaking change
A change is breaking when code written against the old response can fail or misbehave on the new one. From the client's side:
| Change | Breaking? | Why |
|---|---|---|
| A field is removed or renamed | Yes | Code reading it gets undefined or null |
A field changes type (number to string, object to array) | Yes | Arithmetic, comparisons and method calls behave differently |
A field can now be null | Yes | Code that never checked for null now crashes on it |
| A field is missing from some array elements | Yes | The same, for elements that lack it |
| A new value appears in an enum-like field | Often | A switch with no default case, or a hard-coded list, misses it |
| A field is added | Usually not | Tolerant readers ignore it, but see below |
A field stops being null, or is now always present | No | Old code handles both cases already |
| Keys appear in a different order | No | JSON objects are unordered |
The "usually not" matters for Java services. A plain Jackson ObjectMapper rejects unknown fields by default, so adding currency to a response throws UnrecognizedPropertyException: Unrecognized field "currency" in a strict consumer. Spring Boot's preconfigured mapper turns that check off, which is why the same change can break one Java client and not another. Null, Missing, or Unknown tests exactly this across Jackson, Pydantic and Zod.
One kind of change no comparison will find: the same shape with a new meaning. If total moves from pounds to pence, every type check passes. Only documentation and review catch that.
The example: two versions of one response
Version 1 of an orders endpoint:
{
"orders": [
{ "id": "ord_1001", "status": "shipped", "total": 249.99, "customer": { "name": "Ada", "email": "ada@example.com" } },
{ "id": "ord_1002", "status": "pending", "total": 15.5, "customer": { "name": "Grace", "email": "grace@example.com" } }
]
}
Version 2, after a release:
{
"orders": [
{ "id": "ord_1003", "state": "pending", "total": "89.00", "currency": "GBP", "customer": { "name": "Linus", "email": null } },
{ "id": "ord_1001", "state": "shipped", "total": "249.99", "currency": "GBP", "customer": { "name": "Ada", "email": "ada@example.com" } },
{ "id": "ord_1002", "state": "pending", "total": "15.50", "currency": "GBP", "customer": { "name": "Grace", "email": "grace@example.com" } }
]
}
Five things changed, three of them breaking:
- A new order,
ord_1003, appears at the top of the list. Not breaking. statuswas renamed tostate. Breaking.totalchanged from a number to a string. Breaking.currencywas added. Not breaking, for tolerant readers.- The new order has
"email": null, soemailcan now be null. Breaking.
Reading the two documents side by side, the first change is obvious and the fifth is easy to miss. The next sections compare them by machine.
Compare the structure, not just the values
There are two different questions you can ask of two documents:
- By value: which values differ? This is what most diff tools answer, and what you want when checking data.
- By structure: which fields, types and nullability differ? This is the question for API compatibility.
A structural comparison first reduces each document to its shape: every field path with array indices collapsed, the types seen there, and whether it is missing from some elements. Then it compares the shapes. For our two versions, that gives five changes:
| Field | Change | Detail | Breaking |
|---|---|---|---|
orders[].customer.email | Became nullable | string → string | null | Yes |
orders[].status | Field removed | string | Yes |
orders[].total | Type changed | number → string | Yes |
orders[].currency | Field added | string | No |
orders[].state | Field added | string | No |
Two things stand out:
- The nullable email only shows up here. It belongs to the new order,
ord_1003. A value comparison reports that order as one added item and never looks at the types inside it. - A rename appears as a removal plus an addition. No tool can know
statereplacedstatus. When you see a removed field and an added field of the same type, check whether they are one rename.
The structural view also hides noise. The value comparison reports nine changes for these two versions. The structure comparison reports five, and puts the three that matter first.
Arrays: match items by ID, not position
The new order at the top of the list is one change. A naive diff pairs array elements by position, so it compares the new ord_1003 with the old ord_1001, the moved ord_1001 with the old ord_1002, and so on. Every field of every order looks different:
changed orders[0].id "ord_1001" -> "ord_1003"
changed orders[0].customer.name "Ada" -> "Linus"
changed orders[1].id "ord_1002" -> "ord_1001"
changed orders[1].customer.name "Grace" -> "Ada"
... 15 changes in total
Pairing the elements by their id field instead gives the real picture:
removed orders[id="ord_1001"].status "shipped"
changed orders[id="ord_1001"].total 249.99 -> "249.99"
added orders[id="ord_1001"].state "shipped"
added orders[id="ord_1001"].currency "GBP"
... the same four for ord_1002
added orders[id="ord_1003"] { "id": "ord_1003", ... }
Nine changes, each one true. Matching by ID works when every element on both sides has the field, and its values are unique and never null. Common choices are id, _id, uuid, key, code, sku and slug. If no such field exists, position is the only option, and an insertion will look like a change to everything after it.
Exporting the difference as a JSON Patch
A diff for people and a diff for programs are different things. JSON Patch (RFC 6902) is the standard machine format: a list of operations that, applied in order, turns one document into the other. It has six operations: add, remove, replace, move, copy and test. Paths are JSON Pointers.
The patch from version 1 to version 2 starts like this:
[
{ "op": "remove", "path": "/orders/0/status" },
{ "op": "replace", "path": "/orders/0/id", "value": "ord_1003" },
{ "op": "add", "path": "/orders/0/state", "value": "pending" },
{ "op": "replace", "path": "/orders/0/total", "value": "89.00" },
{ "op": "add", "path": "/orders/0/currency", "value": "GBP" }
]
The full patch has 15 operations. Applied to version 1 with Python's jsonpatch library, it produces version 2 exactly.
Notice that the patch works by position again, not by ID. That is unavoidable: JSON Pointer addresses array elements by index, so the patch has to describe the insertion at the top the long way. Use a patch to apply or replay a change, for example in a test fixture or an HTTP PATCH request with the application/json-patch+json media type. Use the structural comparison to review one.
Catching breaking changes in CI
Comparing by eye works once. To catch these changes on every release, save a known-good response as a contract file and compare each new build against it. This dependency-free Node.js script does the structural comparison and exits with code 1 when it finds a breaking change:
// Usage: node check-breaking.js old.json new.json
const fs = require('fs');
function shape(value, path = '$', out = new Map()) {
const entry = out.get(path) || { types: new Set(), optional: false };
out.set(path, entry);
if (Array.isArray(value)) {
entry.types.add('array');
const objects = value.filter(v => v && typeof v === 'object' && !Array.isArray(v));
const keys = new Set(objects.flatMap(Object.keys));
for (const item of value) shape(item, path + '[]', out);
for (const key of keys) {
if (objects.some(o => !(key in o))) out.get(path + '[].' + key).optional = true;
}
} else if (value === null) {
entry.types.add('null');
} else if (typeof value === 'object') {
entry.types.add('object');
for (const [key, v] of Object.entries(value)) shape(v, path + '.' + key, out);
} else {
entry.types.add(typeof value);
}
return out;
}
const nonNull = s => [...s.types].filter(t => t !== 'null').sort().join(' | ');
const [before, after] = process.argv.slice(2).map(f => shape(JSON.parse(fs.readFileSync(f, 'utf8'))));
const problems = [];
for (const [path, old] of before) {
const now = after.get(path);
if (!now) { problems.push(`${path}: removed`); continue; }
if (nonNull(old) && nonNull(now) && nonNull(old) !== nonNull(now)) problems.push(`${path}: type ${nonNull(old)} -> ${nonNull(now)}`);
if (!old.types.has('null') && now.types.has('null')) problems.push(`${path}: can now be null`);
if (!old.optional && now.optional) problems.push(`${path}: now missing from some items`);
}
problems.forEach(p => console.log('BREAKING ' + p));
process.exit(problems.length ? 1 : 0);
Run against our two versions, it prints the three breaking changes and fails:
$ node check-breaking.js v1.json v2.json
BREAKING $.orders[].status: removed
BREAKING $.orders[].total: type number -> string
BREAKING $.orders[].customer.email: can now be null
$ echo $?
1
In a pipeline, fetch the response from staging and compare it with the contract you committed:
curl -s https://staging.example.com/api/orders > orders.new.json
node check-breaking.js contracts/orders.json orders.new.json
The check is only as good as its samples. A field that never appears in the contract cannot be checked, and a field that happens to be null everywhere tells you nothing about its type. Build the contract from a response that exercises every field, including the optional ones.
Doing it in the browser
For a one-off check, such as reviewing a pull request or comparing staging against production, the JSON Diff tool on JSON Keyper does everything in this guide without writing code. Paste the two versions and click Compare:
- Changes lists every added, removed and changed value, with array items matched by their ID field automatically. Switch Match array items to By position if your arrays have no ID.
- Structure shows the shape comparison, with each breaking change badged and counted at the top.
- JSON Patch gives the RFC 6902 patch from the first document to the second.
Numbers are compared exactly as written, so 15.5 and "15.50" show as a change, and so do 12.5 and 12.50 unless you tick Treat 12.50 and 12.5 as equal. Key order is ignored. You can copy or download the result as a Markdown report for a pull request, or as a .json patch file.
Both documents are compared in your browser and never uploaded, so it is safe to paste real production responses.
Compare Two API Responses
Paste an old and a new response to see every change, a structure view with breaking changes flagged, and a JSON Patch. Free, in your browser, no login.
Open JSON Diff