"We didn't break anything — we just cleaned up the response."
Famous last words. Four hours later a mobile app is crashing and a partner integration is returning garbage, because "cleaned up" quietly meant "changed the shape of data other people depend on."
The tricky thing about breaking an API is that the most dangerous changes don't look dangerous. They look like tidying. They pass code review, pass your tests, and ship green — because the breakage doesn't live in your repo. It lives in code you can't see, owned by people who didn't get a heads-up.
Here are nine changes that feel backwards-compatible, why each one isn't, and how to stay safe.
1. Renaming a field
userId → user_id, "for consistency." Every client still reading userId now gets undefined.
A rename is a delete plus an add — and the delete is the part that breaks people.
Safe move: add the new field, keep the old one, deprecate it loudly, remove it later (if ever).
2. Making a response field optional or nullable
It was always there; now it's sometimes null. The client that did payment.currency.toUpperCase() crashes.
Loosening a response you send tightens the assumptions you break downstream.
Safe move: treat "always present" as a promise. If a field genuinely must become optional, that's a versioned change, not a patch.
3. Removing a field "nobody uses"
You can grep your own codebase. You cannot grep your consumers' code, the partner's integration, the Zapier zap someone built in 2023, or the LLM parsing your response right now.
"Unused" means "unused by the consumers I happen to know about."
Safe move: instrument field-level usage in production before you remove anything. Measure, don't assume.
4. Narrowing a type
string → an enum. A wide numeric range → a smaller one. number → integer. Every previously-valid value that no longer fits is now a rejected request.
Widening an input type is safe. Narrowing it is a breaking change for everyone already sending the old values.
Safe move: only ever loosen the inputs you accept. Tightening needs a version bump.
5. Changing a default value
A field defaults to false; you flip it to true "because that's what most people want anyway." Every client relying on the old default silently changes behavior — no error, no failed request, just different results.
Defaults are part of the contract even though nothing in the schema visibly "changed."
Safe move: treat a default change as a breaking change. It is one.
6. Adding a required request field
You add tenantId and mark it required. Every existing client that doesn't send it now gets a 400.
Adding optional fields is safe. Adding required ones breaks everyone who came before.
Safe move: new request fields are optional with a sane default — or they ride a new version.
7. Changing the error shape or status code
400 → 422. { "error": "..." } → { "errors": [...] }. A success 200 → a 204 with no body.
Error handling is code too, and you just broke all of it. The clients that were carefully parsing your error format are now the ones that fail hardest.
Safe move: error contracts are contracts. Status codes and error bodies are load-bearing — version them like anything else.
8. Touching an enum
Renaming a value ("active" → "ACTIVE") or dropping one ("pending" is gone) breaks every client with an exhaustive switch — or a stored value that's now invalid. Even adding a value can break strict consumers that didn't expect a new one.
Safe move: enum values are forever. Add them carefully; never rename or remove without a major version.
9. Quietly changing serialization
The schema "type" didn't change, but the meaning did:
- Dates from Unix seconds to ISO-8601.
- A 64-bit ID that loses precision when JSON serializes it as a float.
- Pagination from offset to cursor.
These are the nastiest because a schema diff often won't even flag them.
Safe move: pin formats explicitly, and diff real payloads, not just the spec.
The thing all nine have in common
Every one of these passes review, passes your tests, and ships green. "Looks backwards-compatible" is a vibe. "Is backwards-compatible" is a check.
The only reliable guard is to compare every change against the contract that's actually in production — in CI, on every pull request — and fail the build on the change classes above. A human will miss a renamed field in a big diff. A diff tool won't.
Full disclosure: I got annoyed enough by this exact problem that I build a tool for it. But you can get most of the way with
oasdiffor any OpenAPI diff wired into CI. Just put something between "looks safe" and "is deployed."
Which of these nine has bitten you? I'm betting #1 or #3. 👇
Top comments (0)