This content originally appeared on DEV Community and was authored by Vlad Pistun
In distributed systems, change is inevitable – but breaking things doesn’t have to be. As your system evolves, so will the data exchanged between services. In Kafka-based architectures, that evolution must be handled carefully. Fail to do so, and you’ll end up with incompatible consumers, corrupted data, and replay nightmares.
This article dives into schema evolution in Kafka, with a sharp focus on using Protobuf and Go to build resilient contracts. We’ll cover versioning strategies, evolution-safe patterns, common pitfalls, and practical examples using kafka-go
and a schema registry (Buf or Confluent).
Why Schema Evolution Matters
When your services communicate through Kafka topics, they rely on a shared contract – the event schema. But these schemas aren’t static. You’ll inevitably need to:
- Add new fields (e.g., tax_code to an Order event)
- Deprecate fields (e.g., replacing total_amount with tax_amonut + taxable_amount)
- Change types (e.g., int → decimal)
Without a disciplined approach to schema evolution, these changes break consumers, especially if they’re deployed out-of-sync or written in different languages.
Breaking change example:
You remove a field that a downstream Go service still expects. On replay, it panics, or worse, silently processes corrupted data.
Kafka Doesn’t Understand Your Schema
Kafka stores bytes – not meaning. That means schema evolution isn’t Kafka’s problem to solve. It’s yours.
To manage this, most production-grade systems use a Schema Registry:
- Confluent Schema Registry (for Avro, Protobuf, JSON Schema)
- Buf Schema Registry
These registries:
- Store schema versions
- Check for compatibility (forward, backward, full)
- Help consumers safely deserialize bytes
Protobuf: The Go-Friendly Choice
For Go services, Protobuf offers strong typing, excellent performance, and good evolution support.
Why not JSON or Avro?
- JSON: No schema enforcement. Easy to break without noticing.
- Avro: Schema evolution support is solid, but Go support is weak and verbose.
Why Protobuf Works Well
- Fields can be marked optional
- Defaults are implicit
- Fields have tags (field numbers), so renaming is safe
- Decoding skips unknown fields by default
Designing for Evolution: Best Practices
1. Add Fields, Don’t Remove
Additive changes are safest. Make new fields optional, and give them safe defaults on the consumer side.
message OrderV2 {
string id = 1;
int32 amount = 2;
optional int32 tax = 3; // added in v2
}
In Go:
type OrderV2 struct {
Id string
Amount int32
Tax *int32 // optional, nil if not present
}
2. Don’t Reuse Field Numbers
In Protobuf, field numbers are sacred. Changing the type of an existing field or reusing an old number for a new field leads to unpredictable behavior.
3. Default Tolerant Consumers
Consumers should handle missing fields gracefully and never rely on a field always being present.
Forward vs Backward Compatibility
Type | Description | Use Case |
---|---|---|
Forward | New consumers can read old events | Upgrade consumers first |
Backward | Old consumers can read new events | Upgrade producers first |
Full | Both directions safe | Best for long-term contracts |
Topic vs Schema Versioning
There are two major strategies:
1. Versioned Schema, Same Topic
- Topic remains orders
- Schemas evolve (v1, v2, v3…)
- Consumers must be aware of schema versions or use registry-based decoding
2. Versioned Topics (orders.v1, orders.v2)
- Clear boundary, simpler consumers
- Harder to replay across versions
- Сan lead to topic sprawl
Recommended: Use versioned schemas + a schema registry, unless you have a good reason to version topics.
Conclusion
Schema evolution in Kafka is a challenge – but not an unsolvable one. With Protobuf, a proper schema registry, and evolution-safe Go code, you can build contracts that withstand change and avoid production surprises.
Design schemas like APIs. Make them clear, compatible, and built to last.
Furthermore: Exploring Buf Stream
Buf recently introduced Buf Stream, a high-performance streaming schema transport designed to simplify Protobuf communication across services. While not yet widely adopted in Kafka ecosystems, it shows promise for future use cases involving real-time schema-bound messaging – especially where tight Protobuf integration and compatibility checks are critical.
At the time of writing, we haven’t tested Buf Stream in production or in Kafka contexts, but it’s worth keeping an eye on as the ecosystem evolves.
This content originally appeared on DEV Community and was authored by Vlad Pistun