Kafka

Kafka Schema Evolution in Practice: A Guide to Preserving Backward Compatibility

2026-04-19
NicheeLab Editorial Team

Preserving backward compatibility means keeping messages written with old Writer schemas readable by the new Reader schema. The BACKWARD setting on Confluent Schema Registry (especially the TRANSITIVE variant) is the linchpin.

This article is aligned with the CCDAK exam scope and focuses on precise terminology and concrete techniques you can apply on the job — safely adding fields, renaming them, and phasing them out incrementally.

Terminology and Compatibility Levels (Frequent CCDAK Topics)

Backward compatibility guarantees that a new Reader schema (typically on the new consumer side) can read data written with old Writer schemas. BACKWARD on Confluent Schema Registry expresses this property, and adding TRANSITIVE extends the guarantee to every version in the full history.

Conversely, when you want to ship a new Producer without breaking existing consumers, you need forward compatibility (FORWARD). FULL requires satisfying BACKWARD and FORWARD at the same time — it is the safest mode to maintain, but it narrows the set of allowed changes. In practice, you pick a level based on rollout order: are you updating consumers first, or producers first?

Based on Avro's reader/writer resolution rules, defaults on added fields, numeric type promotion, and aliases all help preserve compatibility. Protobuf and JSON Schema follow similar principles, but the details differ (for example, in Protobuf the field number is what matters).

  • BACKWARD (TRANSITIVE recommended): new Reader can read data from old Writers
  • FORWARD (TRANSITIVE recommended): old Reader can read data from new Writers
  • FULL: satisfies both at once (the most restrictive set of allowed changes)
  • Picking the right mode based on rollout order is the standard practice
Compatibility LevelRead/Write GuaranteeRepresentative Safe Changes
BACKWARD_TRANSITIVENew Reader can read every old Writer in the historyAdd field (default required), add enum symbol, numeric type promotion (int → long/float/double, etc.), reorder fields, rename field + aliases
FORWARD_TRANSITIVEOld Reader can read every new Writer in the historyAdd field (in a form the old Reader can ignore), keep defaults stable; you cannot remove an existing required field
FULL_TRANSITIVEBoth sides can read each other across the entire historyAdditions are strict (default required); absorb breaking changes through staged migrations

Backward compatibility (BACKWARD_TRANSITIVE) flow

Avro Sv0register Sv1reads old Sv0 w/ defaultsProducer(Sv0)Kafka TopicSchema RegistrySubject: t-value / Compat: BACKWARD_TRANSITIVEConsumer(Sr1)

Setting the compatibility level (example: BACKWARD_TRANSITIVE)

curl -s -X PUT http://localhost:8081/config/t-value \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  -d '{"compatibility": "BACKWARD_TRANSITIVE"}'

# グローバル既定を変える場合(推奨はSubject単位での設定)
# curl -s -X PUT http://localhost:8081/config -H "Content-Type: application/vnd.schemaregistry.v1+json" -d '{"compatibility": "BACKWARD_TRANSITIVE"}'

Core Patterns for Preserving Backward Compatibility

Adding a field is the safest and most common change. In Avro, always assign a default to the new field. Even when the old Writer omits the field, the new Reader can fall back to the default and still read the record.

Numeric type promotion (int → long/float/double, long → float/double, float → double) is valid for backward compatibility on the Reader side under Avro's resolution rules. Adding a new enum symbol is also backward compatible, but removing or reordering symbols requires care.

Renaming a field is breaking by default, but Avro's aliases let you map data written with the old name onto the field with the new name. In Protobuf, the field number is what matters more than the name, so renames are safe as long as the number stays the same.

  • Added fields require a default in Avro. Missing defaults very often trigger compatibility violations.
  • Type promotion is only safe on the Reader side (in the backward-compatibility context).
  • For enums, additions are fine; deletions and replacements are effectively forbidden.
  • Absorb renames with aliases (Avro) or by keeping the same field number (Protobuf).

Avro schema example: adding fields and renaming with aliases

// 旧スキーマ(Sv0)
{
  "type": "record",
  "name": "Order",
  "namespace": "com.example",
  "fields": [
    {"name": "id", "type": "string"},
    {"name": "amount", "type": "int"},
    {"name": "status", "type": {"type":"enum","name":"Status","symbols":["NEW","PAID"]}}
  ]
}

// 新スキーマ(Sr1, BACKWARD互換):フィールド追加+改名
{
  "type": "record",
  "name": "Order",
  "namespace": "com.example",
  "fields": [
    {"name": "id", "type": "string"},
    {"name": "amount", "type": "long"},               // Reader側の型拡張
    {"name": "state", "type": "string", "aliases": ["status"], "default": "NEW"},
    {"name": "coupon", "type": ["null","string"], "default": null} // 新規追加
  ]
}

# 互換性チェック(最新と比較)
curl -s -X POST http://localhost:8081/compatibility/subjects/t-value/versions/latest \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  -d '{"schema": "{ \"type\": \"record\", \"name\": \"Order\", ... }"}'

Staged Migration: Making Breaking Changes Safely

Apparently breaking changes — removing a required field, narrowing a type, removing an enum symbol, or changing a Protobuf field number — should never be done in one shot. Decompose them into stages: first run the old and new shapes side by side in a backward-compatible form, then switch consumers, then producers, and finally remove what is no longer needed.

During migration, pin the Registry compatibility to BACKWARD_TRANSITIVE and only switch briefly to a FORWARD variant if you genuinely need a producer-first phase. Keep in mind that FULL narrows the set of allowed changes even further because it must satisfy both directions simultaneously.

  • Step 1: Add the new field (with a default) and keep the old field around (mark it deprecated, etc.)
  • Step 2: Switch consumers to read the new field, falling back to the old field if needed
  • Step 3: Migrate producers to write only the new field (add a dual-write period if it makes the transition safer)
  • Step 4: Once every consumer has been updated, drop the old field

Registration flow guarded by compatibility checks (pseudocode)

# 1) 互換性は原則 BACKWARD_TRANSITIVE
curl -s -X PUT http://localhost:8081/config/t-value -H "Content-Type: application/vnd.schemaregistry.v1+json" -d '{"compatibility":"BACKWARD_TRANSITIVE"}'

# 2) 登録前に必ずドライラン確認
curl -s -X POST http://localhost:8081/compatibility/subjects/t-value/versions/latest \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" -d '{"schema": "...new schema..."}'

# 3) 問題なければ登録
curl -s -X POST http://localhost:8081/subjects/t-value/versions \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" -d '{"schema": "...new schema..."}'

Choosing a Subject Naming Strategy and Its Impact

Compatibility is evaluated per subject. TopicNameStrategy (for example, topic-name-value) is the common choice and lets you manage compatibility independently per topic. If you want to share a record across multiple topics, RecordNameStrategy shares the compatibility history of identically named records — but watch out for unexpected collisions.

When you mix in multi-topic publishing or Kafka Connect/ksqlDB, confirm up front which subject will be registered (value vs. key, topic-driven vs. record-driven) and align your compatibility level and migration plan accordingly.

  • TopicNameStrategy: intuitive operationally, isolated per topic
  • RecordNameStrategy: great for schema reuse, but wider blast radius when collisions happen
  • TopicRecordNameStrategy: a middle ground — reduces collision risk while keeping reuse

Configuring the subject name strategy on the Kafka Avro Serializer (example)

props.put("value.subject.name.strategy", "io.confluent.kafka.serializers.subject.TopicNameStrategy");
// 例: RecordNameStrategy を使う場合
// props.put("value.subject.name.strategy", "io.confluent.kafka.serializers.subject.RecordNameStrategy");

Production Workflow: Checklist and CI/CD Gates

Gate schema changes with the same rigor as code reviews. Run a dry-run check up front against the Registry's compatibility API, and automate a regression test that reads old-version messages with the new schema using fixture data. Comparing normalized schemas (absorbing field ordering and whitespace differences) keeps reviews focused on real changes.

In production, set the compatibility level explicitly on every subject. When you need to relax it as an exception, file a ticket that captures the time window, impact, and rollback procedure. Keeping an audit map from schema IDs to Git commits is also valuable.

  • Require compatibility-API dry runs in CI
  • Replay-test against old data (reader-side regression check)
  • Manage compatibility levels explicitly per subject
  • Maintain a mapping from schema IDs to release tags

Simple CI dry-run example (Bash)

SUBJECT="t-value"
NEWSCHEMA_FILE="schema_new.avsc"
BODY=$(jq -Rs '{schema: .}' < "$NEWSCHEMA_FILE")

curl -s -X POST "http://localhost:8081/compatibility/subjects/${SUBJECT}/versions/latest" \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  -d "$BODY" | jq .

Consumer Implementation: Caveats and Operations

Backward compatibility lives or dies on consumer robustness. Define defaults on the new Reader schema, ignore unknown fields, and branch on nullability explicitly. Log-compacted topics may deliver tombstones (value=null), so make sure your code is null-safe whether you use Avro, JSON Schema, or Protobuf.

With GenericRecord, do existence checks and apply defaults yourself; with SpecificRecord, you rely on the defaults baked into the generated code. On errors, prefer routing messages to a dead-letter or retry queue over silently skipping them — that way schema and data mismatches stay observable.

  • Never blur the distinction between null and unset
  • Always carry defaults on the schema itself
  • Quarantine exceptions in a DLQ to surface root causes
  • Have a replay plan ready

Defensive read with Avro GenericRecord (Java, sketch)

GenericRecord r = consumerRecord.value();
Long amount = null;
if (r.getSchema().getField("amount") != null) {
  Object v = r.get("amount");
  if (v instanceof Integer) amount = ((Integer) v).longValue();
  else if (v instanceof Long) amount = (Long) v;
}
String state = (String) (r.get("state") != null ? r.get("state") : "NEW");
// coupon は optional
String coupon = r.get("coupon") == null ? null : r.get("coupon").toString();

Check Your Understanding

CCDAK

問題 1

You add a new optional field with a default value to the value schema in Avro. You want to update consumers to the new Reader schema first and still read older data. Which compatibility level on the Schema Registry subject is most appropriate (covering the entire history)?

  1. BACKWARD_TRANSITIVE
  2. FORWARD
  3. NONE
  4. FULL

正解: A

You need a guarantee that the new Reader can read every Writer in the full history, which is exactly what BACKWARD_TRANSITIVE provides. FORWARD guarantees the opposite direction (old Reader reads new Writer), NONE guarantees nothing, and FULL imposes the bidirectional constraint you do not need here.

Frequently Asked Questions

When should I use BACKWARD vs FORWARD compatibility?

Choose BACKWARD (new Reader reads old Writer) when you plan to roll out consumers first, and FORWARD (old Reader reads new Writer) when you plan to roll out producers first. Reserve FULL for the unusual case where you need both guarantees at once.

How can I safely rename a field?

In Avro, register the old name as an alias on the new field. Migrate consumers to the new name in stages and only drop the field with the old name after a sufficient grace period. In Protobuf, field numbers are what matter, so renaming is safe as long as you keep the same field number.

What should I watch out for when changing an enum?

Adding a new symbol is backward compatible, but removing or replacing an existing symbol fails in most compatibility modes. Changing a symbol's meaning is also effectively a breaking change. Stick to additions and treat unused values as deprecated at the application layer.

Check what you learned with practice questions

Practice with certification-focused question sets

無料で問題を解いてみる
Author

NicheeLab Editorial Team

NicheeLab editorial team focused on data engineering and cloud certification learning. Content is structured around practical study needs and official exam domains.


Related articles
Kafka

Kafka Topics & Partitions: Distribution Fundamentals (2026)

How Kafka topics and partitions enable scale — ordering guar...

Kafka

CCDAK Exam Guide: Confluent Certified Developer (2026)

Complete prep for the CCDAK exam — Producer/Consumer API, St...

Kafka

CCAAK Exam Guide: Confluent Certified Administrator (2026)

Pass the CCAAK exam — cluster management, partitions, securi...

Kafka

Kafka Replicas & ISR: Fault Tolerance Explained (2026)

Replica placement, in-sync replicas (ISR), leader election. ...

Kafka

Kafka Offsets: Commit Modes & Consumer Position (2026)

Offset semantics — auto vs. manual commit, __consumer_offset...

Browse all Kafka articles (101)
© 2026 NicheeLab All rights reserved.