Filter concepts by levelShowing all levels.

System Design · Rate of Change and Schema Evolution

Rate of Change and Schema Evolution

Versioning APIs and events, expand-contract database changes, and designing for old and new versions to coexist

  • Versioningadditive changes are free; breaking changes need a deprecation window
  • Expand-contractadd, migrate, then remove — never rename or drop in one step
  • Coexisting versionsold and new instances run simultaneously for the whole rollout

1.Versioning APIs and events carefully

An API or an event schema is a contract between whoever produces it and every consumer that reads it, and unlike a function inside one codebase, that contract cannot be changed and redeployed everywhere at once — a mobile app version from six months ago, a partner's integration, and an internal service that has not been redeployed yet may all still be calling the old contract long after a new one ships. Versioning is the discipline of managing that reality deliberately rather than assuming every consumer upgrades in lockstep with the producer. The safest changes are additive and backward-compatible — adding a new optional field, adding a new endpoint — which every existing consumer can simply ignore without being broken. A breaking change (removing a field, changing a field's type or meaning, changing required parameters) needs an explicit versioning strategy: a new version number in the URL or a header, coexisting for a defined deprecation window with the old version, so consumers migrate on their own schedule rather than breaking the instant the change ships. The same discipline applies to events published onto a queue or log (Kafka, SQS): a consumer reading an event schema has the same lockstep problem as an API caller, except often with less visibility into who all the consumers even are, since a queue or topic can have consumers a producer does not know about at all.

Key features

  • Additive, backward-compatible changes (new optional fields, new endpoints) do not need a version bump — every existing consumer can ignore what it does not recognize
  • A breaking change (removing/renaming a field, changing its type or required-ness, changing an endpoint's semantics) needs an explicit version strategy — a new API version, or a new event schema version — with the old one still served during a deprecation window
  • Common API versioning strategies are a version segment in the URL (/v2/orders), a version header, or content negotiation — the choice matters less than picking one and applying it consistently
  • Events have the same lockstep problem as APIs but often worse visibility: a topic or queue can have consumers the producer does not know about, so "just tell everyone about the breaking change" is not always possible the way it might be for a documented, registered set of API clients
  • A deprecation window (a defined period where both the old and new version are served) is what actually makes a breaking change safe — a breaking change shipped with no old version served at all forces every consumer to migrate at the exact moment the producer chooses, not the moment each consumer is ready
  • Semantic versioning conventions (major.minor.patch) map cleanly onto this: a major version bump signals a breaking change, a minor bump signals a backward-compatible addition — communicating the nature of a change, not just that a change happened

Change type, and whether it needs a version bump

Change type, and whether it needs a version bump
ChangeBackward-compatible?Needs a new version?
Add a new optional fieldYesNo
Add a new endpoint / event typeYesNo
Remove a fieldNoYes — old version stays live during a deprecation window
Change a field's type or meaningNoYes
Make an optional field requiredNoYes
Change an endpoint's or event's success/error semanticsNoYes
A breaking change, versioned and given a deprecation window
  1. T+0

    v2 released

    v1 continues unchanged; both live side by side

  2. T+0 to T+90d

    Deprecation window

    consumers migrate to v2 on their own schedule

  3. T+60d

    Sunset warning

    v1 responses include a deprecation header

  4. T+90d

    v1 retired

    only after the documented window elapses

  1. T+0: v2 released — v1 continues unchanged; both live side by side
  2. T+0 to T+90d: Deprecation window — consumers migrate to v2 on their own schedule
  3. T+60d: Sunset warning — v1 responses include a deprecation header
  4. T+90d: v1 retired — only after the documented window elapses

Command

http
# Two APIs live at once during a deprecation window
GET /v1/orders/42
{"id": 42, "total": 59.99}

GET /v2/orders/42
{"id": 42, "total": {"amount": 5999, "currency": "USD"}}
# v1 kept serving unchanged until its documented
# sunset date; v2 is where the breaking change
# (total's type changed from a plain number to an
# object) actually lives

Additive, backward-compatible changes need no version bump — existing consumers can ignore what they do not recognize. A breaking change needs an explicit new version, served alongside the old one for a defined deprecation window, so every consumer — including the ones a producer does not have full visibility into — migrates on its own schedule rather than breaking the instant the change ships. This applies to events on a queue or log exactly as much as to a request/response API.

2.Backward-compatible database changes

A database schema is read by application code that is not deployed all at once — during any rolling deployment, old application code and new application code query the same database simultaneously for at least a short window, which means a schema change has to work correctly against both versions of the code at once, not just the new one. This is a harder version of the API-versioning problem, because a database usually cannot serve "two versions of the schema" side by side the way an API can serve /v1 and /v2 — there is one live schema, and every currently-running instance of the application, old or new, is reading and writing against it at the same instant. The standard technique for making a genuinely breaking change (renaming a column, changing its type, splitting one column into several) safe is the expand-contract pattern: expand the schema by adding the new structure alongside the old one, deploy application code that writes to both and reads from whichever is authoritative, wait for every old instance to be replaced by new instances that only need the new structure, then contract by removing the old structure once nothing reads it anymore. Skipping straight to the "final" schema — dropping a column or renaming it in one migration — works fine in a single-instance, single-deploy world and breaks immediately in a rolling deployment, because for the length of the rollout, some fraction of running instances are still querying a column that no longer exists.

Key features

  • During a rolling deployment, old and new application code query the same live database schema simultaneously — there is no equivalent of an API's /v1 and /v2 living side by side at the database layer by default
  • The expand-contract pattern has three phases: expand (add new structure alongside old, without removing anything), migrate (deploy application code that works with both, backfill data into the new structure), contract (remove the old structure only once nothing reads it)
  • A one-step migration that renames or drops a column works only if every application instance querying the database is updated at the exact same instant — true for very few real deployments, and false for essentially all rolling/blue-green deployments
  • Adding a nullable column, or a column with a default, is backward-compatible on its own — old code that does not know about the new column simply never references it and continues to work unchanged
  • Renaming a column is not backward-compatible on its own even though it looks like a small change — old code querying the old name breaks the instant the rename lands, so it needs the expand-contract treatment: add the new name, dual-write during migration, remove the old name only once nothing reads it
  • The contract phase should only run once monitoring confirms zero reads/writes against the old structure — removing it based on an assumption ("the rollout should be done by now") rather than verified evidence risks breaking an instance that has not actually redeployed yet

Expand-contract: three phases for a database change that would otherwise break old code

Expand-contract: three phases for a database change that would otherwise break old code
PhaseWhat happensSafe to remove old structure?
ExpandAdd the new column/table alongside the old one; nothing removedNo — old code still needs the old structure
MigrateDeploy code that writes to both, reads from whichever is authoritative; backfill existing dataNo — rollout to all instances is still in progress
ContractRemove the old structureYes — only after confirming zero reads/writes against it
Expand-contract: renaming a column safely during a rolling deploy

Expand

add display_name alongside full_name; nothing removed yet

Dual-write

new code writes both columns; old code still works unchanged

Backfill

copy existing full_name values into display_name

Verify

confirm zero reads/writes against full_name from any instance

Contract

drop full_name only after verification

  1. Expand — add display_name alongside full_name; nothing removed yet
  2. Dual-write — new code writes both columns; old code still works unchanged
  3. Backfill — copy existing full_name values into display_name
  4. Verify — confirm zero reads/writes against full_name from any instance
  5. Contract — drop full_name only after verification

Command

sql
-- Expand-contract for renaming "full_name" to
-- "display_name" across a rolling deployment

-- Phase 1: Expand — add the new column, backfill it
ALTER TABLE users ADD COLUMN display_name TEXT;
UPDATE users SET display_name = full_name;

-- Phase 2: Migrate — new app instances write to both
-- columns; old instances keep working against full_name
-- unchanged. Wait until every instance is on the new code.

-- Phase 3: Contract — only once no instance reads
-- full_name anymore
ALTER TABLE users DROP COLUMN full_name;

A rolling deployment means old and new application code query the same live schema simultaneously for the length of the rollout — a one-step rename or drop breaks whichever code version does not match it. The expand-contract pattern (add the new structure, dual-write and migrate data while both old and new code run, remove the old structure only after verified confirmation nothing uses it anymore) is what makes a genuinely breaking database change safe during a real, staggered rollout.

3.Rolling deployments: designing for coexisting versions

A rolling deployment replaces instances of a service one at a time (or in small batches) rather than stopping every instance and starting the new version simultaneously, specifically to avoid downtime — but the direct consequence is that for the entire duration of the rollout, some fraction of instances are running the old code and some are running the new code, at the same time, serving the same traffic and talking to the same shared dependencies. A design has to actively support that coexistence rather than assume it away: two instances of the same service, one old and one new, might both pull a message off the same shared queue, both write to the same database, or both be behind the same load balancer receiving requests round-robin — and the system needs to behave correctly regardless of which combination of versions ends up interacting with each other at any given moment. This means every change has to be evaluated not just as "does the new version work" but as "does the new version work correctly while some requests are still being served by the old version, reading and writing the same shared state." A change that is perfectly fine once every instance is on the new version, but broken during the mixed period, is not actually safe to ship as a rolling deployment — it needs the same versioning/expand-contract discipline applied to APIs and databases elsewhere in this section.

Key features

  • During a rolling deployment, old and new instances coexist and both actively serve traffic, read/write shared state, and interact with shared queues and databases — this is not a brief instant, it typically spans the entire rollout window
  • A change is only safe for a rolling deployment if it works correctly in all combinations: old instance talking to old, new to new, old to new, and new to old — not just in the fully-new steady state
  • A message queue or event topic is a common place this bites: an old consumer and a new consumer can both be pulling from the same queue simultaneously, so a message format change needs the same backward-compatible treatment as an API or database change
  • Load balancers typically route to instances round-robin or by least-connections, with no awareness of which application version an instance is running — a client cannot assume which version handled its previous request will handle its next one
  • Feature flags are a common complement to rolling deployments: a feature can be deployed as dormant code across all instances first, then activated via a flag once every instance is confirmed on the new version, decoupling "code is deployed" from "feature is active" so behavior does not depend on the exact rollout progress
  • The same discipline extends to background workers and scheduled jobs, not just request-serving instances — a worker fleet mid-rollout has exactly the same old/new coexistence problem as an API fleet

Four combinations a rolling deployment must handle correctly

Four combinations a rolling deployment must handle correctly
CombinationWhen it happensWhat can go wrong if unhandled
Old talking to oldBefore the rollout starts, and for any instance not yet replacedNothing new — this is the pre-rollout steady state
New talking to newAfter the rollout completesNothing new — this is the post-rollout steady state
Old talking to newAn old instance reads data/messages written by a new instanceOld code cannot parse a new field/format it does not expect
New talking to oldA new instance reads data/messages written by an old instanceNew code expects a field/format the old instance never wrote
Old and new instances coexisting mid-rollout
Load Balancer
Old Instance (v1)
New Instance (v2)
Shared Queue
Shared DB
  1. 1route request A
  2. 2route request B
  3. 3publish message (v1 format)
  4. 4consume messagemust handle v1-format messages too
  5. 5write using new schema
  6. 6read same rowmust still work against the new schema
  1. Load Balancer → Old Instance (v1): route request A
  2. Load Balancer → New Instance (v2): route request B
  3. Old Instance (v1) → Shared Queue: publish message (v1 format)
  4. New Instance (v2) → Shared Queue: consume message (must handle v1-format messages too)
  5. New Instance (v2) → Shared DB: write using new schema
  6. Old Instance (v1) → Shared DB: read same row (must still work against the new schema)

Command

text
# A rolling deployment of 10 instances, and the
# actual state of the world at the midpoint:
t=10min (rollout 50% complete):
  instances 1-5: new code (v2)
  instances 6-10: old code (v1)
  load balancer: routes new requests to any of the
    10, regardless of version, round-robin
  shared queue: messages produced by v1 AND v2
    instances are interleaved in the same queue,
    consumed by whichever instance (v1 or v2) is
    next available
  shared database: read and written by both
    versions simultaneously, the entire time

A rolling deployment means old and new instances coexist and both actively serve traffic and share state for the entire rollout window, not just for an instant — a change is only safe to ship this way if it works correctly across all four combinations (old-to-old, new-to-new, old-to-new, new-to-old), which is exactly what the versioning and expand-contract disciplines in this section exist to guarantee. Never assume a client's consecutive calls, or a consumer's messages, come from an already-fully-migrated fleet.

4.Event schema evolution and consumer compatibility

Events published to a log or queue (Kafka, SQS, an event bus) often outlive the exact moment they were produced — a consumer might process an event seconds after it was published, or a replay might reprocess events from months ago, and a schema registry or topic frequently accumulates events written under several different schema versions over its lifetime, all still sitting there to be read. This makes event schema evolution stricter than API versioning in one specific way: an API client makes a new request every time and can be pointed at a new version, but an event consumer often has to be able to read every version of the schema that could still exist in the stream, including old ones written long before the consumer's current code was deployed. Two specific compatibility properties formalize what "safe evolution" means. Backward compatibility means a new schema can read data written with an old schema — a consumer upgraded to understand the new schema can still process old events. Forward compatibility means an old schema can read data written with a new schema — a consumer that has not yet upgraded can still process new events, typically by ignoring fields it does not recognize. A genuinely safe change (adding an optional field with a default, for instance) is compatible in both directions at once; a change that breaks either direction (removing a field a consumer still expects, changing a field's type) needs the same deprecation-window treatment as an API, except now measured against the consumer with the oldest deployed code and the oldest events still eligible to be replayed, not just the current moment.

Key features

  • Backward compatibility: new schema can read old data — a consumer running the newest code can still correctly process events written under an older schema still sitting in the log or replayable from storage
  • Forward compatibility: old schema can read new data — a consumer still running older code can still correctly process events written under a newer schema, typically by ignoring unrecognized new fields
  • A field addition with a sensible default is compatible in both directions: old consumers ignore the new field (forward-compatible), and new consumers supply the default when reading old events that lack it (backward-compatible)
  • Removing a field or changing its type breaks compatibility in at least one direction and needs the deprecation-window treatment — but the relevant window is bounded by both the oldest still-deployed consumer AND the oldest event still eligible for replay, which can be a much longer window than a typical API deprecation period
  • A schema registry (used with Avro, Protobuf, or JSON Schema) enforces compatibility rules automatically at publish time — rejecting a producer's schema change that would violate the configured compatibility mode, catching an unsafe change before it ever reaches consumers
  • Event replay (reprocessing an entire historical log, e.g. to rebuild a derived data store) makes backward compatibility a hard requirement, not a nice-to-have — a consumer that cannot read the oldest schema version in the log cannot correctly replay from the beginning

Backward vs forward compatibility, and what breaks each

Backward vs forward compatibility, and what breaks each
CompatibilityWhat it guaranteesCommon change that breaks it
BackwardNew schema can read old dataRemoving a field new code assumed was optional to skip, but actually still needs a value for
ForwardOld schema can read new dataMaking a new field required, so old code fails validation on an event it does not understand
Both (full compatibility)Any consumer version can read any event versionOnly additive changes with defaults, and never removing/renaming a field
Plotting a schema change by which compatibility it preserves
Add optional field w/ default
safe in both directions
Remove a field
old consumers still expecting it break
Make a field required
old consumers fail validation on new events
Change a field's type
breaks both directions
  • Add optional field w/ default: Preserves forward compatibility, Preserves backward compatibility — safe in both directions
  • Remove a field: Preserves forward compatibility, Breaks backward compatibility — old consumers still expecting it break
  • Make a field required: Breaks forward compatibility, Preserves backward compatibility — old consumers fail validation on new events
  • Change a field's type: Breaks forward compatibility, Breaks backward compatibility — breaks both directions

Command

json
// Event written under schema v1 (still sitting in
// the log, replayable)
{"order_id": 42, "total": 59.99}

// Event written under schema v2 (currency added,
// optional, with an implied default)
{"order_id": 43, "total": 64.99, "currency": "USD"}

// A backward-compatible v2 consumer reading the v1
// event above supplies the default itself:
// currency = event.get("currency", "USD")

// A forward-compatible v1 consumer reading the v2
// event above simply ignores the unrecognized
// "currency" field and proceeds normally

Backward compatibility (new schema reads old data) and forward compatibility (old schema reads new data) are two independent properties, and a genuinely safe change — an additive field with a default — preserves both at once. Event schemas need this discipline more strictly than APIs because a log or topic can hold events spanning years of schema versions, and a replay job exposes the full historical range at once, not just the versions active right now — use transitive compatibility checking, not just pairwise, for any topic that supports full-history replay.

Remember

  • Additive changes need no version bump; breaking changes need a new version served alongside the old one during a deprecation window.
  • Expand-contract makes a breaking database change safe: add the new structure, dual-write while migrating, remove the old structure only after verified zero-use.
  • A rolling deployment means old and new instances interact for the whole rollout, not an instant — every change must work across all four old/new combinations.
  • Event schemas need backward AND forward compatibility, and a replay job exposes the full historical range of schema versions at once — use transitive compatibility checking.

Pro tip

  • Never drop or rename a column in the same migration that stops using it — verify zero access first, based on evidence, not an elapsed-time guess.
  • Introduce a new field name for a changed shape rather than repurposing an old one in place — it is the only way to keep both compatibility directions intact.

Next up

  • Event Ordering and Duplicate Handling
  • Ordering within a partition, not globally
  • Tolerating duplicates and out-of-order events

Sources

  • Google Cloud API Design Guide — Versioning
  • Kubernetes — Rolling Update Deployments
  • Confluent Schema Registry — Schema Evolution
  • Checked 2026-08-26

Spotted a mistake on this page? Tell us.