Event Schemas, Versioning & Evolution
Design event payloads that last: backward and forward compatibility, versioning strategies and handling old events safely.
Events are contracts
Once an event is published, other teams' services depend on its shape. In event-sourced systems you must also read events written years ago. Changing an event carelessly breaks consumers silently — often in production, at night.
A well-designed event
{
"event_id": "5f0c9a2e-3d61-4b8e-9c7a-0f4b2a1d9e13",
"event_type": "EnrollmentCreated",
"event_version": 2,
"occurred_at": "2026-10-29T10:02:11Z",
"producer": "enrollment-service",
"correlation_id": "req-7f3a…",
"data": {
"enrollment_id": 1043,
"student_id": 42,
"course_id": 7,
"fee": { "amount": 5000, "currency": "PKR" }
}
}
- Envelope metadata (ID, type, version, time, producer, correlation ID) is consistent across all events.
dataholds the business payload.- Explicit
event_versionlets consumers handle each shape correctly. - Money as amount + currency, timestamps in UTC ISO-8601, IDs as stable identifiers.
Compatibility rules
| Change | Backward compatible? | Notes |
|---|---|---|
| Add an optional field | ✅ Yes | Old consumers ignore it; new consumers use a default when missing |
| Add a required field | ❌ No | Old events don't have it |
| Remove a field | ❌ Usually no | Consumers may still read it — deprecate first |
| Rename a field | ❌ No | Equivalent to remove + add |
| Change a field's type/meaning | ❌ No | Create a new version or a new event |
Golden rule: consumers should be tolerant readers — ignore unknown fields and supply defaults for missing optional ones. Producers should only make additive changes within a version.
Versioning strategies
- Additive changes only — the simplest; most changes can be expressed as new optional fields.
- Version number in the event (
event_version: 2) — consumers branch on it or upcast. - New event type — for a real change in meaning (
EnrollmentCreatedV2, or better, a new name likeEnrollmentStarted). - Publish both versions for a migration period, then retire the old one once every consumer has moved.
Upcasting old events
An upcaster converts older event versions into the current shape when they're read, so business code only deals with the latest version.
Suppose version 1 stored the fee as a plain number in PKR, and version 2 introduced currencies:
def upcast(event: dict) -> dict:
"""Convert any stored EnrollmentCreated event to the current (v2) shape."""
version = event.get("event_version", 1)
if event["event_type"] == "EnrollmentCreated" and version == 1:
data = dict(event["data"])
data["fee"] = {"amount": data.pop("fee_pkr"), "currency": "PKR"}
return {**event, "event_version": 2, "data": data}
return event
old = {"event_type": "EnrollmentCreated", "event_version": 1,
"data": {"enrollment_id": 7, "fee_pkr": 5000}}
new = {"event_type": "EnrollmentCreated", "event_version": 2,
"data": {"enrollment_id": 8, "fee": {"amount": 40, "currency": "USD"}}}
for e in (old, new):
print(upcast(e)["data"])
Output:
{'enrollment_id': 7, 'fee': {'amount': 5000, 'currency': 'PKR'}}
{'enrollment_id': 8, 'fee': {'amount': 40, 'currency': 'USD'}}
Stored events are never rewritten; the conversion happens on read.
Schema registries
In larger systems, event schemas are stored in a schema registry (commonly with Avro, Protobuf or JSON Schema). Producers register schemas, and the registry rejects incompatible changes before they reach production. See the Kafka tutorial's Schema Registry & Avro lesson for a hands-on example.
Designing events that age well
- Name events after business facts (
CoursePublished), not database operations (CourseRowUpdated). - Include what consumers need, not the entire internal object — every field is a promise.
- Avoid leaking internal details (database IDs from other systems, internal status codes).
- Document events like an API: owner, meaning, fields, examples, version history.
- Test compatibility in CI with contract tests or registry checks.
Common mistakes
- Renaming a field "just to tidy up" and breaking downstream consumers.
- Strict consumers that fail on unknown fields.
- No version information, making it impossible to tell old shapes from new.
- Changing the meaning of a field while keeping its name (e.g.
amountswitching from rupees to paisa).
Key takeaways
- Treat events as public contracts with a consistent envelope and explicit versions.
- Prefer additive, optional changes; consumers should be tolerant readers.
- Upcast old events on read instead of rewriting history.
- Use schema registries and contract tests to catch breaking changes early.