Versioned event schemas help only when producers and consumers agree on what each version means. A producer adds a field to an event, deploys, and moves on. A consumer written six months earlier sees a value it does not understand, rejects the message and starts filling a dead-letter queue. The change looked additive in one codebase, but the real contract lived across several independently deployed systems.
A useful versioning system makes that contract explicit. It gives producers a stable way to identify an event, consumers a precise description of its payload, and both sides a migration path when the meaning can no longer stay compatible. The hard part is deciding which identity changes, who judges compatibility and when an old contract can be retired.
This guide uses CloudEvents 1.0.2, AsyncAPI 3.0.0 and JSON Schema 2020-12 as reference points. The pattern also applies when a team uses Avro, Protocol Buffers or another schema format.
Keep event identity separate from schema identity
An event needs at least two kinds of identity:
- Event type tells a consumer what business occurrence the message represents, such as
com.example.order.shipped.v1. - Schema identifier points to the exact payload contract, such as
https://schemas.example.com/order-shipped/1.3.0/schema.json.
CloudEvents makes the distinction concrete. Its type attribute is the main identity a consumer uses to decide how to process an event. Its dataschema attribute identifies the schema that defines the data value. The CloudEvents primer recommends keeping the same type for a compatible change and changing the type for an incompatible change. It also recommends changing dataschema whenever the schema changes, including compatible revisions.
That split prevents a common mistake: treating a schema file’s version as the only signal a consumer needs. A consumer may route on event type before it ever validates the payload. Another consumer may accept several schema revisions of the same event. Give each decision its own identifier.
Use immutable schema identifiers. If the document at /1.3.0/schema.json changes in place, a replayed event can validate differently next month than it did today. JSON Schema’s $id keyword can give a schema a stable, absolute URI, while AsyncAPI can reference that schema from a message definition. The registry or repository should keep old identifiers resolvable for as long as events using them can be replayed.
Write a compatibility policy before the first change
“Backward compatible” is incomplete unless the team says whose behavior must continue. Producer and consumer compatibility point in different directions:
- A new consumer reading old events needs backward compatibility.
- An old consumer reading new events needs forward compatibility.
- Replays, rolling deployments and multiple consumer versions often require both for a defined period.
The schema format can detect some structural changes, but it cannot prove that business meaning stayed compatible. Changing amount from minor currency units to major units leaves the JSON type as an integer while breaking every calculation. Renaming an enum value may pass through a permissive parser and still change a workflow.
Classify changes in the repository where producers and consumers can review them:
| Change | Typical risk | Default decision |
|---|---|---|
| Add an optional field with a documented default | Older consumers may ignore it | Compatible when consumers allow unknown fields |
| Add a required field | Old events do not contain it | Breaking unless the consumer supplies a safe default |
| Remove or rename a field | Existing consumers may depend on it | Breaking |
| Narrow an allowed value or enum | Previously valid events may fail | Breaking |
| Widen an allowed value or enum | Old consumers may reject the new value | Breaking for forward compatibility |
| Change units, timezone rules or business meaning | Validation may still pass | Breaking |
Treat the table as a starting policy, not a universal law. A consumer that rejects unknown JSON properties makes an added optional field breaking. A Protocol Buffers consumer has different constraints around field numbers. Record the assumptions alongside the schema and test them against the serializers that production code actually uses.
Give compatible and breaking changes different paths
For a compatible change, keep the event type stable and publish a new immutable schema identifier. A producer can start emitting the new revision once compatibility checks pass. Consumers should declare which event types and schema revisions they accept, then fail visibly when a message falls outside that range.
For a breaking change, create a new event type and a new schema identifier. Do not make consumers infer a major version from the payload. A clear type such as com.example.order.shipped.v2 lets routing, metrics and dead-letter handling distinguish the new contract before deserialization.
The new event should also describe a real contract, not a temporary translation artifact. If the business occurrence is still “order shipped,” a new major event type is appropriate. If the occurrence itself changed, name the new fact directly. order.fulfilment.updated should not masquerade as a second version of order.shipped if the two events drive different decisions.
AsyncAPI can hold the channel, operation and message definitions together, including the payload schema and message content type. That gives reviewers a view of which application sends or receives each message. It does not replace runtime evidence, but it gives contract checks and documentation one machine-readable source.
Migrate with a measured overlap
A breaking event contract rarely succeeds as a single deployment. Producers and consumers release on different schedules, and retained messages can outlive both. Use an overlap with explicit exit conditions:
- Inventory consumers. List direct subscriptions, stream processors, analytics jobs, exports and replay tools. Broker subscription lists alone miss downstream copies.
- Publish the new contract. Give the new event type and schema stable identifiers. Add examples that cover optional values, empty collections and boundary conditions.
- Add consumer support first. Consumers accept the new type while continuing to process the old one. Contract tests should run against captured examples from both versions.
- Dual-publish or translate at one controlled boundary. If the system permits it, emit both event types from the source. Otherwise, use one documented translator and track its failures. Avoid several teams writing private mappings.
- Compare outcomes. Count events by type and schema, validation failures, dead letters, processing latency and business-level differences. A zero error rate does not prove that totals or state transitions match.
- Move consumers. Record the owner and cutover date for every consumer. Remove the old path only after the agreed replay window and retention period have passed.
- Retire deliberately. Stop old emission, keep its schema and documentation available, then remove old consumer code in a later change.
This overlap resembles the safe retirement work described in an API deprecation and sunset policy. Event systems add one complication: an old payload may return through replay long after live traffic has moved. The retirement date must account for storage, disaster-recovery procedures and current subscription traffic.
Make failures observable at the contract boundary
Validate at the producer boundary when practical, because that is the cheapest place to reject an event the producer itself cannot describe. Validate again where trust or implementation changes justify it, especially at external ingestion points and translation services.
Do not reduce the result to a single “invalid message” counter. Useful dimensions include event type, schema identifier, producer version, consumer name and validation rule. Keep payloads out of metrics and routine logs when they contain personal or confidential data. A sampled, access-controlled failure record can preserve the rule and a redacted fragment without copying the full event.
Consumers also need an explicit policy for unknown fields, unknown enum values and an unknown schema identifier. Silently accepting everything hides drift. Rejecting every unfamiliar revision turns harmless additions into incidents. The choice belongs in the compatibility policy and in contract tests.
Batch consumers should isolate failures instead of losing a whole group to one bad event. The same per-item accounting used for partial failures in batch APIs can keep successful messages moving while the invalid item goes to a reviewable dead-letter path.
A contract review checklist
Before approving an event-schema change, ask:
- Does the event type still describe the same business occurrence?
- Is the new schema available at a unique, immutable identifier?
- Which old and new producer-consumer combinations were tested?
- Could the change alter meaning without changing the data type?
- How will unknown fields and enum values behave in each client library?
- Does a breaking change have a new event type and an overlap plan?
- Which metrics will show validation errors and business-level divergence?
- How long can old events be replayed, and who owns retirement?
The review should produce an owner, a compatibility decision and evidence from the combinations that matter. A version number without those decisions only labels the risk.
Sources and version notes
Sources checked October 29, 2026:
- CloudEvents Primer 1.0.2, especially its guidance on event type and data-schema versioning.
- AsyncAPI Specification 3.0.0, including message and multi-format schema objects.
- JSON Schema 2020-12 Core, including schema identifiers and references.





