-
Pick the compatibility mode from your upgrade order — it's the whole game. Compatibility is asymmetric and defined by who reads data written under the other schema:
| Mode | Guarantees | Allowed change | Upgrade FIRST |
|---|
| BACKWARD | new consumer reads data from old + new producers | add optional field (w/ default), delete optional field | consumers |
| FORWARD | old consumer reads data from new producer | add optional field, delete field that had a default | producers |
| FULL | both directions | only add/remove optional fields with defaults | either |
| *_TRANSITIVE | same, but vs all prior versions not just the last | — | — |
Default to BACKWARD for events/topics (Confluent's default — consumers lag and replay history, so the new reader must handle old records). Use FORWARD when producers ship ahead of consumers. Use FULL_TRANSITIVE for long-lived event logs you replay from the beginning. The rule of thumb: add a field → forward-safe; remove a field → backward-safe; do both safely → only optional+default.
-
Additive-only is the safe default. Every new field is optional with a default — never required. A new required field breaks every old producer (forward) and every old record (backward) instantly. Concretely:
- JSON / JSON-Schema: add the key, do NOT add it to
required, give consumers a default. Keep additionalProperties permissive (or unevaluatedProperties in 2020-12) so old readers tolerate fields they don't know.
- protobuf (proto3): every field is already optional; new scalar fields default to
0/""/false. Just append with a fresh field number. Use optional (proto3 explicit presence) when you must distinguish "unset" from "zero".
- Avro: a new field must carry a
"default", or it's neither backward- nor forward-compatible — {"name":"x","type":["null","string"],"default":null}. This is the #1 Avro footgun.
-
NEVER remove, rename, or repurpose a field in place — and NEVER reuse a tag/number/position. Renaming = remove + add to every consumer; changing a field's meaning while keeping its name/number is the worst break because it passes schema checks but silently corrupts data. Reuse of an identifier makes old payloads decode into the wrong field. Reserve instead:
A type change (e.g. int32 → string, string → enum) is not additive even if the name stays — it's a remove-and-add. Wire-compatible widenings exist in proto (int32/int64/uint32/bool are interchangeable on the wire; sint*/fixed* are not) but treat them as breaking unless you've verified the exact pair.
-
For a true rename or type change, run expand → migrate → contract (dual-write/dual-read). You cannot atomically change a field across N independently-deployed services. Phase it:
| Phase | Producer | Consumer | DB column |
|---|
| 1 Expand | write BOTH old + new | still reads old | add new col, backfill, dual-write trigger |
| 2 Migrate | writes both | switch reads to new (fallback to old) | — |
| 3 Contract | stop writing old; reserve it | reads new only | drop old col (after grace + replay window) |
Each phase is independently deployable and rollback-safe. The grace window between expand and contract must exceed your longest consumer lag + replay/retention window (e.g. Kafka topic retention) so no in-flight or replayed record still needs the old field. The DB column drop is where db-migration-safety takes over.
-
Deploy in the order the compatibility mode dictates — getting this backwards is the classic outage.
- BACKWARD change (added/removed optional): deploy consumers first, then producers. New consumers can read both shapes; once all consumers handle the new shape, flip producers.
- FORWARD change: deploy producers first — old consumers tolerate the new field (they ignore unknowns), then upgrade consumers to use it.
- FULL: either order, but still roll out gradually and watch dead-letter/parse-error metrics during the canary.
- Never deploy producer and consumer in lockstep assuming atomicity — there is always a window where mixed versions run.
-
Run a schema registry with mechanical compatibility checks, and gate CI on them. Humans miss breaks; the registry doesn't.
- Confluent Schema Registry (Avro/Protobuf/JSON-Schema over Kafka): set per-subject mode and test the candidate before publishing —
curl -X PUT .../config/<subject> -d '{"compatibility":"BACKWARD_TRANSITIVE"}', then POST .../compatibility/subjects/<subject>/versions/latest returns {"is_compatible": true|false}. The Maven/Gradle schema-registry:test-compatibility goal does this in CI.
- protobuf →
buf breaking --against '.git#branch=main' in CI; rules FIELD_NO_DELETE (forces reserved), FIELD_SAME_TYPE, RESERVED_* catch exactly the breaks above. Pair with buf lint.
- Avro standalone →
java -jar avro-tools or the avro-compatibility checker; gate the PR.
- JSON-Schema →
json-schema-diff / oasdiff (for OpenAPI) flag breaking changes.
Make the check fail the build, not warn. The registry's compatibility setting per subject is the contract; CI is the enforcement.
-
Write consumers as tolerant readers — ignore unknown fields, never hard-fail on them. Forward compatibility depends on the reader's behavior as much as the schema:
- JSON: don't use a strict/closed deserializer that throws on unknown keys. Jackson →
@JsonIgnoreProperties(ignoreUnknown = true) / FAIL_ON_UNKNOWN_PROPERTIES=false; Go encoding/json ignores unknowns by default (avoid DisallowUnknownFields); Pydantic → model_config = ConfigDict(extra="ignore") (NOT "forbid").
- Preserve, don't drop, unknown fields on a read-modify-write path, or a round-trip through an old service silently deletes data a newer one added. protobuf keeps unknown fields by default; for JSON, capture them (
@JsonAnySetter, additionalProperties map) and re-emit. This is the subtle one — a "harmless" old service in the middle of a pipeline strips new fields.
- Always provide a default when a field is absent; don't assume presence.
-
When a break is genuinely unavoidable, version explicitly — don't mutate in place. Some changes (splitting one field into two, restructuring nesting, semantic redefinition) can't be made compatible. Then:
- Events: new schema = new subject / new topic (
orders.v2) or an explicit schema_version field; run v1 and v2 in parallel; migrate consumers; retire v1 after the replay window. Never silently change v1's meaning.
- APIs: new path/header version (
/v2, Accept: application/vnd.api.v2+json); deprecate v1 with a sunset header and timeline.
Versioning is the escape hatch, not the default — additive evolution avoids a version bump for the 90% case.
Done = the change is additive (optional + defaulted) or staged through expand→migrate→contract, no field/tag/position is ever removed-without-reserving or repurposed, the compatibility mode matches the deploy order, consumers are tolerant readers that preserve unknowns, and a schema-registry compat check fails CI on any incompatible diff — all proven by the old↔new round-trip and the red-CI test in checks 1–2.