What counts as your API contract
A change is backward compatible when every existing client keeps working without modification. That is a claim about what deployed clients actually do, not about whether your schema diff looks additive. A diff can be purely additive and still break a client that iterates over every key, or that treats an unknown enum value as an error.
The contract is wider than the documented schema. For each endpoint or message, it includes:
- Field names and types, including integer versus string IDs and numeric precision
- Nullability and presence: whether a field can be null, omitted, or empty
- Enum values, both the set you send and the set you accept
- Status codes and error shapes: which code a given failure returns and how the error body is structured
- Defaults applied when a client omits a field
- Ordering of results, even when unspecified
- Pagination semantics: cursor format, page size limits, stability across pages
- Rate limits and the headers or responses that signal them
Semantic Versioning requires software to declare a public API that is "precise and comprehensive." The declaration is necessary but not sufficient. Hyrum's Law observes that with enough consumers, every observable behavior gets depended on by somebody. That includes error message text, response ordering, and timing. So owners must decide explicitly which observed behaviors they will honor and document the rest as unsupported, ideally before someone builds on them.
This checklist is for anyone who owns an API whose consumers they don't control or can't deploy in lockstep with. That covers public APIs, internal services with many teams calling them, mobile backends with old app versions in the field, and event schemas with independent subscribers.
How does backward compatible evolution work?
The server and its clients deploy independently, so at any moment the server is talking to clients built against older versions of the contract. Backward compatible evolution means the server never changes the contract in a way those older clients can't handle. The work happens on four fronts:
- Restrict what can change in place. Changes that old clients can safely ignore, such as new optional request fields and new endpoints, ship directly. Changes that clients might choke on are handled with the techniques below or shipped behind a new version.
- Rely on tolerant readers. Clients are expected to ignore what they don't recognize. In JSON, that means skipping unknown keys. In Protocol Buffers, the wire format already lets a decoder skip unknown field numbers, provided you never change the type of an existing field number or reuse a removed one. Mark removed numbers and names as
reservedso they can't come back with a different meaning. - Change shape in phases, not in one step. Renames, type changes and restructures go through expand, migrate and contract. The old and new forms coexist until telemetry shows nobody depends on the old one.
- Enforce it mechanically. A CI step diffs the schema against the last release and blocks breaking changes. Contract tests and request replay cover behavior that the schema can't express.
Each section below covers one of these mechanisms in detail.
Classifying changes: usually safe, conditionally safe, breaking
Every proposed change falls into one of three buckets. The bucket depends on what clients actually do with your payloads, not on what the schema permits.
| Category | Examples | Why it lands here |
|---|---|---|
| Usually safe | New optional request field with a server-side default; new endpoint | Existing clients never send or call it, so their behavior is unchanged |
| Conditionally safe | New response field; new enum value; new error code | Safe only if clients ignore unknowns. Strict deserializers that reject unknown fields, generated clients with closed enums, and exhaustive switch statements without a default branch all fail on them |
| Breaking | Removing or renaming a field; changing a type; optional to required; tightened validation | Requests or responses that worked yesterday now fail to parse or get rejected |
The breaking row also covers semantic changes where the schema stays the same. Switching a duration from seconds to milliseconds, changing the default sort order, returning timestamps in a different time zone, or making a retried POST create duplicates instead of deduplicating will all pass a schema diff. They still break every client that depended on the old behavior. Schema tools cannot see these changes, so a reviewer has to catch them.
Server-side defaults have a cost. When you add an optional field with a default, that default becomes part of the contract. Clients that omit the field depend on that value forever, and changing it later is a semantic break.
The rule "additive equals safe" only holds when your clients are tolerant readers. If you can't verify that, treat conditionally safe changes as breaking. This applies to third-party SDKs, old mobile builds, and consumers generated from a strict schema.
How the client and server halves fit together: tolerant reader and conservative acceptance
Compatibility is a contract with two sides. The server promises not to break what clients rely on. Clients promise not to rely on things the server never guaranteed. When either side breaks its promise, a "safe" change turns into an outage.
The client half: tolerant reader
A tolerant reader:
- Ignores unknown fields instead of rejecting the payload.
- Doesn't depend on field order or on the order of array elements unless that order is documented.
- Reads only the fields it needs. It does not bind the whole response to a strict model.
- Handles unknown enum values with an explicit fallback branch.
Publish these rules as part of your client contract. If you don't, consumers will reasonably assume a strict schema, and every addition you make becomes a risk.
The enum fallback is where tolerance can hurt you. Suppose a client maps an unrecognized status to UNKNOWN and moves on. It stays up, but it may now treat a refunded order as pending. Tolerance keeps the client running, and it also hides changes in meaning. Make the fallback path observable: log the raw value and increment a metric. That way a new value shows up on a dashboard instead of in a support ticket.
The server half: conservative acceptance
On the input side, anything you previously accepted is part of the contract. That has two consequences:
- Keep accepting old field names and formats alongside the new ones.
- Never tighten validation, such as a shorter max length or a stricter date format, without a deprecation window.
Dual input handling has costs:
- Branching: parsing code needs extra branches for each accepted variant.
- Test matrix: every accepted variant needs its own tests.
- Attack surface: each lenient path is another way in. For example, you have to decide what happens when a request sends both the old and new field with conflicting values.
Budget for these costs, and remove the old paths on schedule.
import logging
from collections import Counter
from enum import Enum
log = logging.getLogger("orders.client")
fallback_counter = Counter()
class OrderStatus(Enum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
UNKNOWN = "unknown" # fallback for values this client doesn't know yet
def parse_status(raw: str) -> OrderStatus:
try:
return OrderStatus(raw)
except ValueError:
# Tolerant reader: don't crash on a new server value.
# But make the fallback visible so a semantic change (e.g. "refunded")
# shows up on a dashboard instead of in a support ticket.
log.warning("unknown order status %r, using UNKNOWN", raw)
fallback_counter[f"order_status_unknown:{raw}"] += 1
return OrderStatus.UNKNOWN
def handle_order(payload: dict) -> None:
# Read only the fields this client needs; ignore everything else.
order_id = payload["id"]
status = parse_status(payload.get("status", ""))
if status is OrderStatus.UNKNOWN:
# Don't guess: avoid actions that assume a known state.
log.info("order %s held for review", order_id)
return
log.info("order %s is %s", order_id, status.value)
A tolerant client that maps unrecognized enum values to an explicit UNKNOWN branch, logging the raw value and counting it so new server values are noticed instead of silently misinterpreted.
Implementing renames and restructures with expand, migrate, contract
A field rename looks trivial, but every deployed client sees it as a deletion plus an addition. Splitting it into three phases keeps each client working at every step. Take customerId being renamed to accountId:
| Phase | Server writes | Server accepts | Move on when |
|---|---|---|---|
| Expand | customerId and accountId, same value |
either; rejects both if they conflict | new field is shipped and documented |
| Migrate | both | either | old-field usage is zero, or the announced date passes |
| Contract | accountId only |
accountId only |
— |
Expand. Add the new field and populate both fields from one internal value so they can't drift. In the schema, mark the old field deprecated and name its replacement. In OpenAPI, that means deprecated: true on the customerId property, with a description reading "Use accountId." In GraphQL, it means customerId: ID @deprecated(reason: "Use accountId") next to accountId: ID. Generated clients and linters can then show the warning where consumers will see it.
Migrate. Measure usage for each consumer, not as one total. A total tells you usage is falling. A per-client breakdown tells you which team to contact. On the request side, log the caller's identity each time the old field arrives. On the response side, you only get this data if the protocol shows which fields a client selects, as GraphQL does. Otherwise you have to rely on consumer contract tests or ask the owners directly.
Contract. Remove the old field only when its usage reaches zero or the announced date passes. Choose one of these rules and publish it.
Cost. Until the contract phase, you pay for larger payloads, two code paths and tests for conflicting inputs. If a long tail of consumers never migrates, the migrate phase can last for months. Set the removal date during expand, not later. That date is what lets you finish.
// Expand/migrate handling for the customerId -> accountId rename.
// One internal value feeds both output fields, so they cannot drift.
interface OrderInput {
accountId?: string;
customerId?: string; // deprecated: use accountId
}
interface OrderOutput {
accountId: string;
customerId: string; // deprecated; dropped in the contract phase
}
// Old-field usage per consumer. In production, emit a metric tagged
// with the caller identity instead of keeping counts in memory.
const legacyFieldUsage = new Map<string, number>();
export function resolveAccountId(input: OrderInput, callerId: string): string {
const { accountId, customerId } = input;
if (customerId !== undefined) {
// Record who still sends the old field, so you know which team to contact.
legacyFieldUsage.set(callerId, (legacyFieldUsage.get(callerId) ?? 0) + 1);
}
// Accept either field, but reject requests where the two disagree.
if (accountId !== undefined && customerId !== undefined && accountId !== customerId) {
throw new Error("accountId and customerId conflict; send accountId only");
}
const id = accountId ?? customerId;
if (id === undefined) throw new Error("accountId is required");
return id;
}
export function toResponse(accountId: string): OrderOutput {
// Write both fields from the same value until the contract phase.
return { accountId, customerId: accountId };
}
Server-side handling during the expand and migrate phases: it accepts either field, rejects conflicting values, counts old-field usage per caller and writes both fields from one internal value.
Running deprecation as a process, not an announcement
The removal date only matters if the rest of the deprecation process supports it. An announcement tells people what is changing. The process is how you confirm they actually changed.
The steps
- Mark the element in the schema and the docs. Use the deprecation marker your schema format provides, so generated clients and linters can warn people at build time. Readers of a changelog are a small subset of your callers.
- Publish a removal date next to the marker. Without a date, consumers have no deadline to plan around.
- Record usage of the deprecated path per consumer. Use a client ID, credential, or service identity. An aggregate request count can tell you traffic is falling, but it can't tell you who still needs to move.
- Contact the remaining callers directly with their own usage data. A message that says "your service called this 4,000 times last week" gets more attention than a broadcast does.
- Remove the element only when the date has passed and the usage data shows zero callers. If only one of those is true, you aren't ready.
Decide on exceptions before you need them
Deprecation dates slip when a critical internal consumer or an external customer can't migrate in time. Write the policy down before that happens. It should say who can approve an extension and for how long, whether you will run a compatibility shim and who maintains it, and when you will break a consumer that doesn't respond. If you make these decisions during the incident, the loudest team usually gets its way.
Telemetry also misses callers that run rarely. A quarterly batch job or a year-end reconciliation can show zero calls for weeks. Choose an observation window that covers your longest known periodic workload before you treat zero as zero.
Automating detection in CI and choosing between versioning and in-place evolution
Breaking-change detection belongs in CI. On every pull request, the pipeline step should:
- Fetch the schema artifact from the last released tag. That is the OpenAPI document, the compiled protobuf descriptors or the GraphQL SDL.
- Diff it against the branch's schema.
- Fail the build on any change classified as breaking, unless the change also introduces a new major version.
Diff against the released artifact, not the main branch. Main may already contain an unreleased break, and comparing against it would hide that break.
Schema diffs only see the shape of the API. They miss changed defaults, a different sort order, stricter validation, and fields that now return null where they did not before. Consumer-driven contract tests catch some of these, because each consumer encodes the behavior it depends on. The limit is that they only cover consumers who wrote tests. Replaying recorded production requests against the new build covers callers you don't know about. Its coverage is limited by the recording window and by how thoroughly you scrub sensitive data from the recordings.
When a break is unavoidable, ship it as a new major version or a parallel endpoint. Never ship a breaking change under the same version identifier. A version label exists so callers can tell incompatible changes apart (Semantic Versioning).
| Approach | Detection coverage | Maintenance cost | Fits when |
|---|---|---|---|
| In-place evolution | Schema diff plus contract tests and replay | Lowest, one code path | Changes are additive or expand/contract works |
| Parallel endpoint | Scoped to one operation | Moderate, two handlers | A single operation needs a new shape |
| New major version | Whole surface, compared per version | Highest, since every fix, alert and security patch lands twice | Many coordinated breaks |
Versioning too eagerly leaves clients spread across many versions and multiplies that patching work. Versioning too reluctantly packs v1 with flags and conditionals until breaking changes slip through unnoticed.
#!/usr/bin/env bash
set -euo pipefail
SCHEMA_PATH="${SCHEMA_PATH:-api/openapi.yaml}" # or SDL / descriptor set
MAJOR_FILE="api/MAJOR_VERSION" # holds an integer, e.g. 2
: "${SCHEMA_DIFF_CMD:?set SCHEMA_DIFF_CMD to your schema diff wrapper}"
git fetch --tags --quiet
BASE_TAG="$(git describe --tags --abbrev=0 --match 'v*' HEAD)"
echo "Comparing ${SCHEMA_PATH} against release ${BASE_TAG}"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
git show "${BASE_TAG}:${SCHEMA_PATH}" > "$WORK/base_schema"
base_major="$(git show "${BASE_TAG}:${MAJOR_FILE}")"
head_major="$(cat "$MAJOR_FILE")"
if [ "$head_major" -gt "$base_major" ]; then
echo "Major version ${base_major} -> ${head_major}: breaking changes allowed."
exit 0
fi
if ! $SCHEMA_DIFF_CMD "$WORK/base_schema" "$SCHEMA_PATH"; then
echo "Breaking change under major version ${head_major}." >&2
echo "Make it additive, add a parallel endpoint, or bump ${MAJOR_FILE}." >&2
exit 1
fi
echo "No breaking schema changes detected."
A tool-agnostic CI step that fetches the schema from the last released tag, diffs it against the branch, and fails on breaking changes unless the branch also bumps the API's major version.
When should it be avoided?
Strict backward compatibility costs ongoing work, and in some situations the cost isn't justified or the old behavior shouldn't be preserved at all.
- You control every client and deploy in lockstep. If the only consumer lives in the same repository and ships in the same deploy as the server, an atomic change is simpler than expand and contract. This holds only when lockstep is guaranteed. If any instance can run an older build during a rolling deploy, or a message queue can hold payloads written by the old version, you are back to needing compatibility for at least the length of the rollout or queue retention.
- The API is explicitly pre-release. An API published as experimental, alpha or
0.x(which Semantic Versioning treats as unstable) can change freely, as long as that status was clear to consumers before they integrated. - Preserving the old behavior is a security or correctness problem. If a field leaks data it shouldn't, or an endpoint accepts input that corrupts state, keeping it working for compatibility's sake keeps the defect alive. Break it deliberately, notify affected consumers directly, and treat it as an incident rather than a normal deprecation.
- The old semantics can't be emulated. Sometimes the new model can't faithfully produce the old response, for example when one record has been split into several and there is no meaningful single value to return. A shim that returns approximate data can be worse than a clean break, because clients keep running on wrong data.
- The compatibility layer outweighs a coordinated migration. With two or three known internal consumers whose owners can schedule a change, a planned cutover may cost less than months of dual code paths. The illustrative estimate in the next section shows how the balance can tip either way.
In each case, avoiding compatibility work is a decision to break clients. Make it explicitly, record it, and communicate it. Don't let it happen by accident.
What are the alternatives?
In-place backward compatible evolution is one strategy among several. The others trade different costs.
- Coordinated cutover (flag day). Server and all clients switch at an agreed time. It needs no dual code paths, but it requires every consumer to be known, reachable and able to deploy on schedule, and rollback means reverting several systems together. It fits small, internal consumer sets and fails for public APIs and installed mobile apps.
- New major version. Run
v1andv2side by side and let clients move when ready. Clients get a clear signal, and the cost is maintaining both surfaces untilv1is retired. The table in the previous section compares it with parallel endpoints and in-place evolution. - Parallel endpoint or operation. Add a new operation with the new shape and deprecate the old one. This keeps the version scope narrow when only one operation needs to break.
- Client-selected versions. The client states which contract version it wants, through a path segment, a request header or a media type in the
Acceptheader. The server keeps translators between its current internal model and each supported external version. This lets the server evolve internally while pinned clients keep their old contract, but the set of translators grows with every version you keep alive. - Per-client opt-in. Gate new behavior behind a flag tied to the client's identity, then flip clients individually and make the new behavior the default once enough have moved. This works well for semantic changes such as a new default sort order, and requires the same usage telemetry as deprecation.
- Translation at the edge. A gateway or adapter service rewrites old-format requests and responses to and from the new format, so the core service carries only the new shape. The compatibility logic still exists. It has moved to a component that someone must own, test and eventually retire.
- Consumer-selected fields. Protocols where clients name the fields they want, as with GraphQL selection sets, make additions invisible to existing clients and give per-field usage data for removals. They don't remove the need for deprecation. They make it easier to measure.
These approaches combine. A typical pattern is in-place evolution for additive changes, expand and contract for renames, and a new major version reserved for the rare release that bundles many unavoidable breaks.
Impact on cost and engineering time, and the pre-merge checklist
Compatibility discipline is not free. The costs and savings land in different places, which is why teams underestimate both.
| Where it costs time | Where it saves time |
|---|---|
| Maintaining dual read/write paths during expand-and-contract | Fewer incident rollbacks caused by a client you forgot existed |
| Adding and watching per-field, per-client telemetry | Fewer coordinated multi-team deploys timed to a cutover |
| Long migration windows before old fields can be removed | Breaks found by a CI schema diff instead of in production |
| Running parallel versions and patching each one | Consumers upgrade on their own schedule |
The costs are planned work. The savings come from avoiding unplanned work, and unplanned work is the expensive kind because it interrupts several teams at once.
Illustrative estimate (assumed numbers, not a benchmark). Assume each breaking change shipped without discipline causes one coordinated deploy across 4 teams at 3 engineer-hours per team. Assume it also has a 25% chance of an incident costing 20 engineer-hours. Disciplined handling costs 8 extra hours of dual paths and telemetry.
Saved hours per change = (teams × hours per team) + (incident probability × incident hours) − discipline overhead = (4 × 3) + (0.25 × 20) − 8 = 9 hours
If you change the inputs, the result can go negative. A single internal client with a lockstep deploy may not justify the overhead.
Pre-merge checklist
- Shape: fields, types, required-ness or enum values added, removed or changed
- Behavior: defaults, ordering, error codes, validation or side effects changed
- Callers: telemetry shows who calls the affected path, not just who you assume does
- Old clients: existing clients work unmodified against the new server
- Rollback: the change can be reverted without corrupting data written in the new shape
- Deprecation: the notice and the removal date are recorded where consumers will see them
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to backward compatible api changes, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Consumers learn about a breaking change at review time rather than from production errors.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.