How to Evolve Avro Schemas with Forward Compatibility
Introduction
Schema evolution is a recurring operational concern for teams running Apache Kafka in production. As event-driven systems mature, data models change: fields are added, types are refined, and business requirements shift. Without a disciplined approach to schema management, these changes break downstream consumers, corrupt data lakes, and trigger cascading failures across stream processing pipelines.
Forward compatibility is the compatibility mode you choose when producers must be upgraded before consumers. Under FORWARD, data written with the new schema can still be read by consumers running the previous schema. This guide is an operations-focused walkthrough for evolving Avro schemas under forward compatibility in the Schema Registry & Connect Ecosystem, covering the resolution mechanics, the exact allowed and disallowed changes, CI/CD testing, and the gotchas platform and SRE engineers hit in production.
We assume you are already familiar with the fundamentals in Schema Evolution and Compatibility in Confluent Schema Registry. If you need a refresher on the compatibility types and how the registry enforces them, review that reference first.
How Forward Compatibility Actually Works
A precise mental model matters here, because forward and backward compatibility have mirror-image rules and it is easy to apply the wrong one.
In Avro’s schema resolution, every read involves two schemas:
- the writer’s schema — the schema the data was serialized with (the producer’s schema), and
- the reader’s schema — the schema the application deserializing the data expects (the consumer’s schema).
Under FORWARD compatibility, the registry checks that data produced with the new schema can be read by consumers still using the previous schema. So when the registry validates a new version, the new schema plays the role of the writer and the previous schema plays the role of the reader. (Backward compatibility is the opposite: the new schema is the reader and the previous one is the writer. This single swap is the source of most schema-evolution mistakes.)
From Avro’s record-resolution rules, two facts drive everything:
- If the writer’s record contains a field not present in the reader’s record, the reader ignores it.
- If the reader’s record has a field with no default that the writer’s record lacks, resolution fails with an error.
Translating those rules into the forward direction (new = writer, old = reader) gives the allowed changes:
-
Adding a field is forward-compatible, with or without a default. The new producer writes the extra field; the old consumer (reader) simply ignores it because the field is absent from its schema. A default is not required to register an added field under
FORWARD. (Requiring a default to add a field is the backward rule — don’t confuse the two.) -
Deleting a field is forward-compatible only if that field had a default in the previous schema. After deletion, the new producer (writer) stops emitting the field, so the old consumer (reader) must fall back to its default. If the old schema had no default for that field, resolution fails and the registry rejects the new version.
-
Reordering fields is always safe. Avro matches record fields by name, not position.
-
Renaming a field is not forward-compatible unless handled with aliases (see below), because name-based matching means the dropped name behaves like a deletion and the new name like an addition.
-
Numeric/string type changes are governed by Avro’s promotion rules, which are directional — covered in the unsafe-operations section.
The practical takeaway: under FORWARD you upgrade all producers first, then upgrade consumers at your own pace. Set this with the registry’s FORWARD or FORWARD_TRANSITIVE compatibility level. FORWARD checks the candidate schema only against the latest registered version; FORWARD_TRANSITIVE checks it against every previously registered version.
Configuring the Schema Registry for Forward Compatibility
Compatibility can be set globally, per subject, or both. The common pattern is a global default with per-subject overrides.
Set the global compatibility level to FORWARD:
curl -X PUT -H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data '{"compatibility": "FORWARD"}' \
http://localhost:8081/config
Override it for a specific subject:
curl -X PUT -H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data '{"compatibility": "FORWARD"}' \
http://localhost:8081/config/my-topic-value
Read back the effective setting for a subject:
curl -X GET http://localhost:8081/config/my-topic-value
With this in place, the registry rejects any registration that violates forward compatibility: a new version is accepted only if a consumer using the previous version (or, under FORWARD_TRANSITIVE, any previous version) can still deserialize data written with it.
Safe Operations Under Forward Compatibility
Adding a Field
Adding a field is the workhorse of forward-compatible evolution. Start from this PaymentEvent schema:
{
"type": "record",
"name": "PaymentEvent",
"namespace": "com.example.finance",
"fields": [
{"name": "transactionId", "type": "string"},
{"name": "amount", "type": "double"},
{"name": "currency", "type": "string"}
]
}
Add a merchantCategory field:
{
"type": "record",
"name": "PaymentEvent",
"namespace": "com.example.finance",
"fields": [
{"name": "transactionId", "type": "string"},
{"name": "amount", "type": "double"},
{"name": "currency", "type": "string"},
{"name": "merchantCategory", "type": "string", "default": "UNKNOWN"}
]
}
Under FORWARD, this registers whether or not merchantCategory has a default, because the old consumer (reader) never had this field and simply ignores it. The default shown here is not what makes the addition forward-compatible — it is purely good hygiene, so that the same schema also satisfies BACKWARD/FULL if you later tighten the mode, and so that older data remains readable by new consumers. Keep adding defaults as a habit, but understand that under pure FORWARD they are optional for additions.
Deleting a Field
Deleting a field is forward-compatible only when the field had a default in the schema you are deleting it from. After deletion the producer stops writing the field, and the old consumer (reader) must substitute its default; without one, Avro signals an error and the registry rejects the version.
If the field has no default today, do it in two steps: first register an intermediate version that adds a default to the field, then register a later version that removes it.
Reordering Fields
Field order is irrelevant in Avro — matching is by name. Reorder freely to improve readability or group related fields.
Adding a Branch to a Union
If a field is a union, you can add a new branch to it. For example, a field typed ["null", "string"] can evolve to ["null", "string", "int"]. Avro resolves unions by matching the writer’s selected branch against the reader’s union: a value the producer writes as the new int branch has no matching branch in the old consumer’s union and will fail to resolve, so in practice the new branch is only emitted once consumers have been upgraded. The existing null and string values continue to resolve unchanged.
Unsafe Operations and How to Mitigate Them
These changes are rejected by the registry under FORWARD. Knowing them lets you plan migrations without downtime.
Changing a Field’s Type
Avro permits directional type promotion during resolution: int is promotable to long, float, or double; long to float or double; float to double; and string ⇄ bytes. Promotion only happens from the writer’s type to the reader’s type.
Under FORWARD the new schema is the writer and the old schema is the reader. Widening a field from int to long means the writer now emits a long while the reader still expects an int — and there is no long-to-int promotion. The change is therefore not forward-compatible and is rejected. (Counter-intuitively, the opposite narrowing — new schema int, old schema long — is forward-compatible, since the writer’s int promotes up to the reader’s long.)
Mitigation: add a new field with the desired type, populate both fields during a deprecation window, migrate consumers to the new field, then delete the old field once no consumer reads it.
Renaming a Field
Renaming userName to username is, to Avro’s name-based matching, a delete of userName plus an add of username. The delete half is the problem: the old consumer (reader) still has userName and, unless it has a default, fails resolution.
Mitigation: Avro aliases let a reader treat a writer’s field name as one of the reader’s own. Aliases are evaluated on the reader’s schema, so they help when you control the consumer’s schema:
{
"name": "username",
"type": "string",
"aliases": ["userName"]
}
Because the alias must exist on the reader (the old consumer’s schema) to take effect, aliases are most useful for backward-compatible reads by new consumers of old data. For a pure forward-compatible rename where you cannot redeploy old consumers, prefer the add-new-field-and-deprecate approach above.
Making a Field Required
Removing a field’s default value makes it effectively required. This is not forward-compatible: if a producer omits the field, an old consumer that lacks a default cannot resolve it.
Mitigation: never remove a default from an existing field. If you must guarantee a field is always populated, enforce it in application code, not by tightening the schema.
Testing Forward Compatibility in CI/CD
Test schema changes before they reach production, using either the registry’s compatibility API or the Confluent Maven plugin.
Check a candidate schema against the latest registered version without registering it. Add ?verbose=true to get the reason on failure:
curl -X POST -H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data '{"schema": "{\"type\":\"record\",\"name\":\"PaymentEvent\",\"fields\":[{\"name\":\"transactionId\",\"type\":\"string\"},{\"name\":\"amount\",\"type\":\"double\"},{\"name\":\"currency\",\"type\":\"string\"},{\"name\":\"merchantCategory\",\"type\":\"string\",\"default\":\"UNKNOWN\"}]}"}' \
'http://localhost:8081/compatibility/subjects/my-topic-value/versions/latest?verbose=true'
A {"is_compatible": true} response means the schema can be registered. On failure, verbose=true returns the specific resolution errors (in a messages array). To check against all prior versions at once, POST to /compatibility/subjects/my-topic-value/versions (no version segment), which honors the subject’s transitive setting.
For Java projects, the kafka-schema-registry-maven-plugin provides a test-compatibility goal:
<plugin>
<groupId>io.confluent</groupId>
<artifactId>kafka-schema-registry-maven-plugin</artifactId>
<version>7.8.0</version>
<configuration>
<schemaRegistryUrls>
<param>http://localhost:8081</param>
</schemaRegistryUrls>
<subjects>
<my-topic-value>src/main/avro/PaymentEvent.avsc</my-topic-value>
</subjects>
</configuration>
<goals>
<goal>test-compatibility</goal>
</goals>
</plugin>
Run it with:
mvn io.confluent:kafka-schema-registry-maven-plugin:test-compatibility
(The short form mvn schema-registry:test-compatibility also works once the plugin is bound in the build.) Wire this into pull-request checks so violations are caught before merge. The Avro specification is the canonical source for resolution rules, and the Confluent compatibility documentation details how the registry enforces each mode.
Operational Best Practices
Version Schemas in Source Control
Treat .avsc files as first-class artifacts. Keep them in a repository alongside the producer and consumer code, in a layout that mirrors your namespaces:
schemas/
src/main/avro/
com/example/finance/
PaymentEvent.avsc
Let the build register the current schema; rely on the registry’s version history (not separately named _v2 files) for the audit trail of changes.
Choose the Subject-Naming Strategy Deliberately
The default TopicNameStrategy ties the subject to <topic>-value/<topic>-key. If several topics carry the same record type and you want one set of compatibility rules across all of them, switch to RecordNameStrategy or TopicRecordNameStrategy, which key the subject on the record’s fully qualified name. Note that this also changes how multiple event types can coexist on a single topic.
Monitor Schema Registry Metrics
Schema Registry exposes JMX metrics that you can scrape into Prometheus and Grafana. The documented global MBeans include:
kafka.schema.registry:type=master-slave-role— attributemaster-slave-role, returning1on the primary (leader) instance and0on secondaries. Alert if no instance reports1.kafka.schema.registry:type=jetty-metrics— connection and thread-pool health (connections-active,request_queue_size, thread-pool usage).kafka.schema.registry:type=jersey-metrics— per-endpoint request rates and latencies, prefixed by the endpoint, e.g.subjects.versions.register.request-rate.
There is no built-in MBean that simply counts subjects or schema versions; track registration volume from the register endpoint’s request rate, and alert on sudden spikes that may signal a misbehaving producer.
Plan for Rollbacks
Forward compatibility does not, by itself, make a producer rollback safe. If you may need to revert a producer to an older schema, that older schema must still satisfy the configured compatibility check against whatever versions are registered. In practice this means honoring deprecation windows before deleting fields or changing types. Inspect a subject’s versions and settings before any rollback:
curl http://localhost:8081/subjects/my-topic-value/versions
curl http://localhost:8081/config/my-topic-value
Handle Default Values Carefully
Defaults are load-bearing in the forward direction — they are what an old consumer falls back to when a field is deleted. Choose semantically safe defaults: a 0 default on amount could trip a fraud-detection threshold. Prefer an explicit sentinel (-1) or null (for a nullable field) that consumer code can test for, rather than a value that silently looks like real data.
Conclusion
Forward compatibility is the right mode when producers must move ahead of consumers. Get the direction straight — under FORWARD the new schema is the writer and the previous schema is the reader — and the rules follow: add fields freely, delete only fields that had defaults, never narrow a type or strip a default, and reach for aliases only when you control the reader.
The registry enforces these rules at registration time, but enforcement is a safety net, not a design strategy. Version your schemas, test every change in CI with the compatibility API or the Maven plugin, monitor the registry’s JMX metrics, and budget deprecation windows for the changes that cannot be made in a single step. The Avro specification and the Confluent schema-evolution documentation are the authoritative references when a change is ambiguous.