Backend Systems · API Styles
API Versioning and the Mechanics of Schema Evolution
Which schema changes are actually breaking, where a version identifier belongs, and how deprecation windows and data migrations interact with old clients.
Versioning an API is usually discussed as a numbering scheme, which is the least interesting part of the problem. The decision that matters is which changes require a new version, and the honest answer is that a change is breaking when an existing client’s behaviour changes in a way that client did not ask for. That definition depends on what clients do with the response, not on the shape of the diff, so a field removal can be harmless to one client and catastrophic to another.
The practical approach is to design the schema and the client contract so that most changes never become breaking, and to reserve version identifiers for the minority that cannot be absorbed. Additive evolution handles the common case. A version number is the escape hatch for the rest, and the cost of using it is that the old surface must keep working for as long as old clients exist.
Breaking is defined by the client
A change is safe if every conforming client continues to receive what it needs and interprets it the same way. Removing a field is breaking for clients that read it. Renaming a field is the same change with worse ergonomics. Changing a type is breaking when the new type does not decode into the old one: an integer that becomes a string breaks JSON clients, while widening an integer from 32 to 64 bits usually does not.
Several categories are breaking without looking like it. Adding a new value to a closed enumeration breaks clients that switch exhaustively on the known values, which is why enumerations are better declared open and clients instructed to tolerate unknown values. Tightening validation is breaking: a request that was accepted last month and is rejected now, with no schema change visible in the response. Making a required field optional is safe for readers and dangerous for writers, because a client that omits it now receives a default it never intended. Changing a default value is a silent behaviour change for every client relying on it. Changing the status code for an error is breaking for clients that branch on it, even when the new code is arguably more correct.
Response headers and error bodies count as part of the schema. A rate-limit header that disappears, or a problem-details type URI that changes, is as breaking as removing a JSON field. So is a change to pagination ordering, because clients that merge pages assume a stable order, as cursor pagination explains.
Additive change and the tolerant reader
The rule that makes evolution tractable is that clients ignore fields they do not recognise and never depend on the absence of a field. That is the tolerant reader pattern, and it has to be designed into the client, documented in the API contract, and tested, because the default behaviour of many decoders is to reject unknown members. A strict JSON decoder that maps into a fixed struct with unknown-field rejection turns every additive server change into a client outage.
On the server side, additive change means new optional fields, new endpoints, new enumeration values, and new optional request parameters with defaults that preserve existing behaviour. It means never reusing a removed field name for a different meaning, and never changing the meaning of an existing field. Deprecation should be visible in the documentation and, where the schema format supports it, in the schema itself. OpenAPI has a deprecated flag on operations and fields; protobuf has reserved for numbers and names so that a deleted field cannot be reintroduced with a different type. Avro and other schema registries carry compatibility rules that a schema registry can enforce before deployment.
type Customer struct {
ID int64 `json:"id"`
Name string `json:"name"` // canonical field, present since v1
FullName string `json:"full_name"` // deprecated alias, removed after the window
Locale string `json:"locale,omitempty"` // added in a later revision, ignored by old clients
}
The dual field in that struct is a real cost: the payload carries the same string twice for the length of the deprecation window. It is still cheaper than coordinating a flag day across every client, and it lets each client migrate on its own schedule. The alternative, changing the field in place, requires every client to update before the server does.
Where the version identifier lives
There are four common placements, and each has different consequences for caches, routing, and client ergonomics.
A path segment, as in /v2/orders, makes the version part of the resource identity. Caches key on it without extra configuration, logs show it, and routing can dispatch to different deployments with no content inspection. The cost is that the same resource now has several names, and links must be generated with the right prefix. It also encourages copying an entire route tree per version rather than running versions side by side in one codebase.
A query parameter, as in /orders?version=2, has the same cacheability property because the query string is part of the cache key, but it is less visible in tooling and easy to omit. A custom media type in Accept, as in application/vnd.example.v2+json, keeps the URI stable, which is the most faithful expression of the idea that the version selects a representation. It requires Vary: Accept for caches and it surprises clients that do not set the header, which then silently receive the default version. A dedicated header such as API-Version: 2 is easier to set than a media type but requires Vary as well and is invisible to browser navigation.
None of these solves the hard part, which is that old versions must remain deployed and operated. The number of supported versions is an operational budget: each one needs its own tests, its own monitoring, and its own data-compatibility guarantees. Supporting the current version plus one predecessor is a common ceiling, and the ceiling should be a documented policy rather than an emergent property of who complained loudest.
Version numbers and Semantic Versioning answer a different question. SemVer describes compatibility of a library’s surface across releases, with major versions for breaking changes, minors for additions, and patches for fixes. An HTTP API can adopt the same vocabulary in its changelog, but the version clients send should not be a full three-part SemVer string, because patch releases are not supposed to change behaviour and clients should not have to care about them. A single major number, incremented for breaking changes, is enough.
Deprecation as an operational process
A deprecation is a period during which an old surface still works and its users are told to move. The process needs three things: a signal, a deadline, and evidence that the signal reached the clients who depend on it.
The signal can be a response header. RFC 8594 defines Sunset, an HTTP-date after which the resource is expected to become unresponsive, applicable to the target URI and to any resource named in a Link header with rel="sunset". A machine-readable sunset date is more useful than a paragraph in release notes because a client can log it, and an operator can grep for it. A Deprecation header carrying a date or a link to documentation serves the related purpose of marking the surface as deprecated while it still works. A Warning header is the older mechanism and remains valid.
The deadline has to be long enough for a client to ship a change, which depends on release cycles outside the API owner’s control. Fixed windows measured in months are common; the useful discipline is to publish the policy and apply it uniformly, rather than to extend deadlines on request until the old version is permanent.
Evidence is the part most often skipped. Usage telemetry per version, per client identifier, and per endpoint is what turns a deprecation from a hope into a managed migration. Without it, the sunset date arrives with unknown traffic still on the old version, and the choice is to break someone or to postpone. Client identification through an API key, a client-supplied header, or a version marker is imperfect but sufficient to rank consumers by volume. Sunsetting a version with no users is easy; sunsetting one with an unknown long tail requires per-client contact, which requires knowing who they are.
Data migrations that outlive the version
An API version is a view over data, and the data model changes on its own schedule. A field that is removed from v2 may still be read by v1, so the column cannot be dropped until v1 is gone. A field that is renamed in v2 requires a compatibility layer that maps the old name to the new storage location for as long as v1 exists.
This is the expand-and-contract pattern applied across API versions. The expand phase adds the new representation and writes both, at the cost of duplicate storage and the risk of divergence. The migration phase backfills old rows. The contract phase removes the old representation, and can only start when no supported version reads it. Each phase is a deployment, and the ordering constraint is what makes API version support a prerequisite for schema cleanup rather than an independent concern. Message formats have the same property, which is why wire-format compatibility rules are stricter than they first appear.
A versioning policy that ignores this coupling produces a database with columns nobody can drop and code paths nobody dares delete. The policy should state that dropping storage requires the corresponding API surface to be retired, and the retirement schedule should be planned with the storage cleanup in mind from the start.
Contracts that break quietly
Three changes escape review because no schema diff shows them. The first is a change in a shared enumeration’s meaning: adding PENDING_REVIEW to a status field is additive, but a client that treats any status other than the three it knows as an error now fails. Open enumerations with a documented default branch are the mitigation. The second is a timestamp format change, from an epoch integer to an RFC 3339 string or the reverse. Both are valid representations, and a client parsing one will fail on the other, so the format is part of the type. RFC 3339 also permits offsets that some parsers reject, and a value like 2025-06-26T00:00:00+02:00 is not interchangeable with a naive local time in every client library.
The third is a change in numeric precision. A monetary amount sent as a JSON number is parsed as a double by clients that use the language’s default number type, and a value with more than about fifteen significant decimal digits loses precision. Amounts should be sent as strings or as integer minor units, and that decision is a schema decision that cannot be changed later without a version. The same reasoning applies to 64-bit identifiers, which lose precision in JavaScript above 2^53 and are routinely sent as strings for that reason. These are not style preferences; each is a compatibility constraint that survives every later attempt to simplify the payload.
References
- RFC 8594: The Sunset HTTP Header Field — IETF
- RFC 3339: Date and Time on the Internet: Timestamps — IETF
- Semantic Versioning 2.0.0 — Tom Preston-Werner