API Design
Fixing a Bug Can Be a Breaking Change
Your consumers built against your behaviour, not your documentation. Correcting a bug they worked around is an outage you shipped on purpose.
Oct 20267 min read
You find a bug. It is small, it is obviously wrong, and the fix is four lines. You ship it in a patch release, because that is what patch releases are for, and a week later someone you have never spoken to opens a ticket saying your API broke their integration.
They are right. You did.
Your consumers did not build against your documentation. They built against your behaviour, and if that behaviour was wrong and they noticed, they wrote something to cope with it. The coping code is load-bearing now. Correcting the behaviour is what takes it out from under them.
The contract you published by accident
The formal name for this is Hyrum’s Law, after Hyrum Wright at Google:
With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.
Which reads like a joke about scale until you write down what “observable” actually covers. Not the fields and the types, which everybody remembers:
- What order a collection comes back in
- The exact wording of an error message
- How long a request takes
- Whether an empty value is
nullor missing from the payload entirely - Whether a decimal serialises as
1.0or1 - Which status code a particular failure uses
- What the page size is when nobody asks for one
None of them is in an OpenAPI document. All of them are in the responses, and the responses are what people integrate against.
Four ways a fix becomes an outage
The value they kept was the wrong one
This one I shipped myself. api-toolbox, my Laravel package for standards-compliant APIs, sends an ETag with a resource and checks If-Match on writes, so two people editing the same thing can’t quietly overwrite each other. On a nested route like /folders/{folder}/documents/{document}, it was taking the ETag from the first model in the route instead of the last. So a document’s responses carried its folder’s ETag, and a write to the document was checked against the folder.
I found it while building an example app on top of the package, and the fix was small: use the last model, not the first. It went out in a patch release, which is exactly where a fix like that belongs. The bug was worse than it looks, too. The ETag comes from a model’s updated_at, so unless the app touched the folder on every change, editing a document left the folder’s ETag exactly as it was, and the protection was quietly not protecting anything.
Now think about a client that keeps an ETag with its local copy, say an app that syncs its changes when it’s back online. Before the fix, it stored the folder’s ETag and sent it back in If-Match, and that worked, because the server was checking against the folder as well. After the fix, the server checks against the document, the stored ETag doesn’t match, and every one of those writes comes back 412 Precondition Failed.
As far as I know, nobody was caught by it. But somebody could have been, and the fix being correct wouldn’t have changed that for them.
The workaround becomes the dependency
Your pagination has an off-by-one and hands back twenty-one records for limit=20. A consumer noticed months ago, said nothing, and slices the array to twenty on their side.
You fix the off-by-one. They still slice to twenty, so they now drop one record from every page. Nothing errors. That is the worst version of this, because it will be weeks before anybody notices the numbers are wrong, and by then the cause is twenty deploys back.
The wrong status code was load-bearing
A validation failure returns 500 when it should return 422. Somewhere out there a consumer’s HTTP client retries 5xx and leaves 4xx alone, which means a class of their bad requests has been quietly fixing itself on the second attempt for months.
You correct it to 422. The retries stop, correctly, and failures that used to disappear start reaching their users instead. You improved your API and caused an incident in theirs on the same afternoon.
You were accepting something you should not have
Validation was looser than documented, something got through that should not have, and it is in their production payloads right now. Tightening the check is the right call and it is a hard failure on a request that worked yesterday.
The part that matters
In all four the fix is correct, and I am not telling you to leave bugs in production.
The problem is that “is this correct?” and “is this breaking?” are two separate questions, and answering the first tells you nothing about the second. Teams run them together constantly, and semantic versioning quietly encourages it: patch releases are for bug fixes, bug fixes are safe, so this is safe. That middle step is where it goes wrong.
The test takes about ten seconds:
Could a reasonable consumer have written code that works against the current behaviour and stops working against the new one?
Not “does the spec allow this”. Not “should they have relied on that”. Could they, reasonably, and would it break. If the answer is yes then it is a breaking change, and what you do about that is a separate decision from what you call it.
What to do instead
You still ship the fix. You stop pretending it is invisible.
Send Deprecation and Sunset on the affected responses. In Laravel that is one middleware:
final readonly class AnnounceDeprecation{ public function handle(Request $request, Closure $next): Response { $response = $next($request);
// RFC 9745: a Structured Field Date, which is a Unix timestamp // prefixed with @. Not an HTTP-date, and there is no boolean form. $response->headers->set('Deprecation', '@1767225600'); // 1 Jan 2026
// RFC 8594: an HTTP-date. A different format entirely, and it // must not be earlier than the Deprecation date. $response->headers->set('Sunset', 'Wed, 01 Jul 2026 00:00:00 GMT');
$response->headers->set( 'Link', '<https://docs.example.com/v2-migration>; rel="deprecation"', );
return $response; }}Two things in there catch people out. The headers take different date formats, which is historical rather than principled. And the Sunset timestamp must not be earlier than the Deprecation one, because a resource cannot stop working before it is deprecated.
Then go and find out who is actually affected. Log the version and the endpoint per consumer, and the list is usually short enough to email. That is the entire reason to measure it. “Some consumers might depend on this” is a shrug; “these four accounts called it last month” is a morning’s work.
And if the fix genuinely cannot wait, ship it and say so anyway. An announced break is a different thing from a surprise, even when the calendar looks identical.
Requests and responses break in opposite directions
For a request, you are the reader, so accepting more is safe and accepting less breaks people. For a response you are the writer, and it inverts: returning less breaks people, and returning more is risky too, because a strict consumer-side schema rejects the field you just added. Adding a field to a response is only safe if every consumer ignores what it does not recognise, and that is a property of their code rather than your intentions.
I have written the full version of all this up on apiguide.dev, including the asymmetry table and why widening an enum belongs in the same category: what counts as a breaking change.
Go and look at the last few bug fixes you shipped to a public API, and put that ten second question to each of them.
Related
More in API Design
Both Ends of the Wire
I built a small CRM API on api-toolbox and its SDK on sdk-toolbox, in one repository, tested against each other. My own two libraries disagreed within a day.
Oct 2026 · 8 min read
API DesignMost of Your SDK Has Already Been Designed, Too
Retries, rate limits, pagination, lost updates: the API already tells your client what to do. sdk-toolbox makes a PHP SDK listen, one RFC at a time.
Oct 2026 · 19 min read
API DesignAn Example App That Needs Its Features
Flagpole is a feature flag service built on kit, where every HTTP standard is there because the domain needs it. Building it found four bugs that kit's own tests never caught.
Oct 2026 · 6 min read
Same subject
In other formats
sdk-tools
A set of tools you can use to help make better SDKs.
API Design
VideoCreating Pulsara's REST API
🚀 Join us LIVE for a thrilling coding adventure as we dive into the backbone of Pulsara, our exclusive social network for developers! In this episode of 'Realistic Product Development', we'll be hand
API Design
TalkThe 10 REST Commandments: Principles for Better API Design
API Design