Writing Backward-Compatible API Change Specs
I have shipped breaking API changes while believing they were additive. The spec looked fine, the schema diff looked small, and the damage came from a rule I had not written down. This is the shortlist I now insist on seeing in any API change spec before I will approve it.
Field note: backwards compatible until a real client proves it
I do not call a change backwards compatible because the schema validator accepts it. I call it compatible after old clients, generated clients, and at least one representative workflow keep working with the new response.
Compatibility proof: - Old web client parses response without code changes. - Mobile client handles new enum value through fallback path. - Generated SDK compiles against the new schema. - Contract test proves old webhook consumer ignores new field.
The Only Rule That Matters: Widen Inputs, Narrow Outputs
If you remember one sentence, remember this one. A server can accept more than it used to, and it can return less than it used to. The reverse is always breaking. Every other rule in this article is a corollary of that sentence.
When I review a spec, I draw a line through the page. On the left, what the client sends. On the right, what the server returns. If inputs are shrinking or outputs are growing, I stop and interrogate the change. Growing outputs is the sneaky one: most people assume adding a field to a response is free, but it is not free for typed clients that decode with a strict schema, and it is not free for contracts that say unknown fields are an error. The spec has to state which world the API lives in.
Optional, Required, and the Myth of the Free Field
Adding an optional input field is safe. Adding a required input field is always breaking, even if you claim old clients "should" start sending it. The spec must mark every new input as optional with a defined default, or declare the change breaking and schedule a version bump.
Output fields are the opposite shape. Adding one is usually safe for clients that ignore unknowns and breaking for clients that do not. If your platform has both kinds of consumers, the spec has to name them and say which group tolerates additions. Do not handwave this.
Enum Expansion: The Most Common Silent Break
Here is the example that taught me this rule the hard way. We had an endpoint that returned a status field with values pending, active, and cancelled. A product request came in to add paused. The schema diff showed one new variant. The spec said "additive, non-breaking."
Within an hour of rollout, a Rust consumer started panicking in production. Their code did an exhaustive match:
match status {
Status::Pending => render_pending(),
Status::Active => render_active(),
Status::Cancelled => render_cancelled(),
}
The Rust compiler had guaranteed that match was total at build time. Adding a variant at the wire level did not update their build. A TypeScript client using a discriminated union and never-typed default branch would have had the same experience at runtime.
The fix is a single sentence I now require: Consumers of this enum are open-world. New variants may appear without a version bump. Clients that match exhaustively must add a catch-all branch. Or the opposite: This enum is closed. Adding a variant is breaking and requires a new version. Pick one. Write it down.
Nullable, Non-Nullable, and the Defaults Trap
Going from nullable to non-nullable on an output is breaking. The reverse is safe in theory and dangerous in practice, because clients that have never seen null will dereference it and crash. Either direction needs a migration note in the spec, not just a schema change.
Default value changes are the category people miss. The schema shape is identical, so the diff tool stays quiet, but behavior changed. If an optional page_size used to default to 20 and now defaults to 50, every existing client is paying different costs, seeing different pagination boundaries, and possibly hitting rate limits they never hit before. I treat every default change as breaking unless the spec proves otherwise with a consumer audit.
Renames, Reshapes, and the One-Way Door
I never approve a rename. The pattern is always add-the-new-name, deprecate-the-old-name, remove-the-old-name in a later major version. The spec has to call out all three steps, including the removal date, or the deprecation becomes permanent technical debt.
Error payloads follow the same logic with extra teeth. Adding a new error code is usually safe because clients should have a default branch for unknowns. Reshaping an error object is breaking. Changing the semantics of an existing code, where the code stays the same but now means something different, is the worst case: silently breaking, invisible in a schema diff, guaranteed to corrupt client retry logic.
Pagination, Rate Limits, and Serialization
Three areas the spec must address explicitly because nobody thinks of them as "the schema":
Pagination. Reducing a documented max_page_size is breaking. Adding a new optional page-size parameter while keeping the old default is safe. Changing the cursor encoding is breaking even if the cursor is opaque.
Rate limits. Any tightening of a published limit is breaking. If a limit is documented anywhere a consumer can read, treat it as contract.
Serialization. Flipping from JSON to a binary format is breaking regardless of how "equivalent" the wire format claims to be. Adding a new content-type that negotiates via Accept with a safe fallback is additive. The spec has to show the negotiation flow, not just assert it.
Acceptance Criteria in Given/When/Then
- Given a v1 client that decodes status with exhaustive match
When the server returns a newly added status variant
Then the client receives an error the spec explicitly permits
And the change is documented as breaking in the changelog
- Given a client that omits the new optional input field
When the server processes the request
Then the server applies the documented default
And the response is byte-identical to the pre-change response
- Given a published rate limit of N requests per minute
When the spec proposes lowering the limit
Then the change is treated as breaking
And a deprecation window is scheduled before enforcement
The Reviewer's Checklist
Before I approve any API change spec, I run through this list out loud. If any item is unanswered, the spec goes back.
- Does every new input field have an explicit default and an optional marker?
- Is every new output field accounted for against strict-schema consumers by name?
- Does every enum change declare open-world or closed-world semantics?
- Has every nullability change been cross-checked against actual client code paths?
- Is every default-value change flagged as breaking until a consumer audit proves otherwise?
- Does any rename have an add-deprecate-remove plan with a removal date?
- Does any error change distinguish addition from reshape from semantic drift?
- Are pagination bounds, rate limits, and serialization formats treated as first-class contract?
What I Write at the Top of Every Spec
Two lines, non-negotiable. First: This change is additive / breaking / silently breaking. Pick one and defend it. Second: The impacted consumer classes are X, Y, Z, and the migration obligation for each is the following. If I cannot write those two lines honestly, the spec is not ready. If I can, the rest of the document writes itself, because the hard decisions have already been made.
Contract Review Packet to Copy
Use this when the work touches API behavior, schema, events, retries, or consumer expectations. The packet makes compatibility and release evidence explicit.
API contract review packet: Writing Backward-Compatible API Change Specs Decision to make: - Write backward-compatible API change specs with additive fields, safer input widening, enum expansion, default values, and release review rules. Owner check: - Product owner: - Engineering owner: - QA or operations reviewer: Scope boundary: - In scope: - Out of scope: - Assumption that still needs approval: Acceptance evidence: - Test or fixture: - Log, metric, or screenshot: - Manual review step: Contract boundary: no release without compatibility classification, consumer impact, retry behavior, and rollback notes. Reviewer prompt: - What would still be ambiguous to someone who missed the planning meeting? - What evidence would make this safe enough to ship?
Second-pass reviewer note: compatibility needs a consumer list
The article is stronger when compatibility is treated as a consumer claim, not a provider opinion. I added language that pushes readers to name the affected clients before approving a change.
Before approval: - List consumers by name, not by category. - Mark generated clients separately. - Decide whether old clients need a migration window. - Attach one old-client test for the highest-risk path.
Editorial Review Note
Reviewed Apr 28, 2026. This update added a reusable artifact, checked the article against the related topic hub, and tightened the next-step links so the page works as a practical reference rather than a standalone essay.
Topic Path
This article belongs to the API Contracts track. Start with the hub, then use the checklist, template, or tool below on a real project.
Keep Reading
Editorial Note
Last reviewed Apr 28, 2026: examples, internal links, and reusable review blocks were checked for practical specificity.
- Author details: Spec Coding Editorial Team
- Editorial policy: How we review and update articles
- Corrections: Contact the editor