Writing Backward-Compatible API Change Specs

Writing Backward-Compatible API Change Specs
Spec Coding Editorial Team · Spec-first engineering notes

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.

Published on 2026-03-09 · Updated 2026-05-06 · 8 min read · Author: Spec Coding Editorial Team · Review policy: Editorial Policy

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.

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.

Keywords: backward compatibility · API versioning · schema evolution · enum expansion · breaking changes

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.

Editorial Note

Last reviewed Apr 28, 2026: examples, internal links, and reusable review blocks were checked for practical specificity.