Versioning and Migrating a Product Event Schema
How to change event schemas without breaking the systems depending on them.

A product event schema is the contract between what happens in an application and what a business believes is happening. When that contract drifts without anyone tracking the change, the damage rarely shows up as an error. It shows up as a segment that quietly loses members, a funnel that quietly gains fake conversions, or a lead score that quietly stops meaning anything. This piece is about how to version and migrate that schema without breaking the systems that depend on it.
Event data from a customer data platform isn't just something analysts look at after the fact. It drives journey orchestration, audience qualification, lead scoring, and activation pipelines while they run. Every event fired carries an implicit promise to whatever downstream system reads it: this name means this action happened, this property holds this value, this enum represents this exact lifecycle state. Break that promise without warning anyone, and every system built on it keeps running, just wrong. For product-led growth teams specifically, this isn't an engineering inconvenience. PQL scoring and cohort-based activation only work if the behavioral telemetry feeding them stays clean and consistent release after release, and a broken schema turns directly into missed pipeline.
None of this means schema drift is avoidable, or even undesirable. New user journeys, new platforms, revised consent rules, new activation use cases: all of these legitimately change what a schema needs to capture. What matters is when the schema will change. It's whether that change gets managed on purpose or happens by accident.
How schema drift accumulates silently and what breaks first
Drift almost never starts with someone deciding to rewrite the schema. It starts with a dozen small, locally sensible calls made by engineers who each see only their corner of the system, usually under a deadline, usually without anyone checking what else depends on the field they just touched.
The failure modes that follow are well documented, and they share a pattern: nothing crashes. An enum value gets respelled, and an audience size drops for no visible reason. A required property becomes optional on one platform, and downstream logic that assumed it was always populated starts choking on nulls. During a migration, both the old and new event names fire for the same user action, and funnel and conversion numbers inflate without anyone noticing the duplication. A property's meaning shifts mid-quarter, and a dashboard metric ends up straddling two incompatible definitions before and after the release, with nobody flagging the seam. Warehouse transformation logic keeps referencing fields that no longer exist, or that now carry a different type than the query expects. A pipeline change delays event delivery just enough to break a time-windowed campaign. Channel or source properties get redefined without backward compatibility, and attribution reporting fragments across the change.
The expensive failures aren't syntax errors. They're semantic breaks: the event validates fine, gets accepted, and then quietly delivers the wrong meaning to everything reading it. A field renamed in web tracking can turn into a null attribute three systems downstream in the warehouse. A redefined enum value can silently split what used to be one segment into two. Neither trips an alert. For anyone running activation off this data, the diagnostic question is whether the event still sent." It's whether every downstream use case, every segment, every cohort, every journey trigger, every adoption signal, still behaved the way it was supposed to.
Building an event contract before version numbers become necessary
Versioning only works when there's an explicit contract defining what has to be preserved. A schema with no defined semantics can't be versioned at all. It can only be replaced, and replacement is where the silent breaks above come from.
A working event contract needs to spell out the event's precise business meaning, not just its name. It needs the trigger condition and timing: when, exactly, does this fire. It needs required properties separated from optional ones explicitly, not left to whoever happens to be writing the tracking code that week. It needs data types and allowed formats, defined enum values (free-form strings are easy to emit and brutal to govern later), identity fields with clear precedence rules, notes on channel-specific quirks when the same event fires from web, mobile, and server, an owner who approves changes, and a list of the dashboards, audiences, and cohort definitions that depend on it.
Keep the required core small and stable, and let enrichment happen through optional fields around it. If everything on an event is optional, no consumer downstream can reliably depend on anything, and the contract stops functioning as a contract. Enum values deserve more governance attention than they usually get: changing one is often a breaking change for segmentation rules and dashboard logic even when the field name itself never moves.
Naming matters more than it looks like it should. The common industry pattern is object_action in snake_case, past tense, so project_created or integration_connected. Capitalization isn't cosmetic either: "Song Played" and "song played" get captured as two distinct events in some platforms, which quietly forks a metric in two. Some tools organize events by product surface using a category:object_action structure to keep related events grouped together for anyone building a report later. And dynamic values never belong in the event name itself, they belong in properties, full stop.
A small set of global properties, present on every single event, gives the whole system a stable scaffold, fields like event_id, event_version, timestamp, user_id, anonymous_id, platform, and app_version, among others. These are what versioning strategies actually hang off of later. A minimal tracking plan, one that covers signup, workspace setup, core activation actions, feature adoption, and the monetization lifecycle, is far easier to version cleanly than a schema that tries to capture everything at once. If the same event can't be reused across multiple reports, that's usually a sign the schema is incomplete.
Distinguishing breaking from non-breaking changes before touching production
Before choosing any versioning approach, a team needs a clear line between changes that are safe to ship without coordination and changes that require it.
Safe, additive changes include new optional or nullable fields, new nested objects, new enum values added (not swapped in to replace old ones), and new topics or streams built specifically for incompatible versions. These can go out without asking every downstream consumer for permission first.
Destructive changes are a different category entirely: removing or renaming a field, making a nullable field non-nullable, changing a field's data type, replacing or redefining what an enum value means, or shifting the business meaning of a field while its name stays put. That last one is the sneakiest of the bunch. If "price" quietly shifts from meaning base price to meaning final price, that is a breaking change even though the schema's shape looks identical on paper. Altering the timing of when an event fires, so it now lands at a different point in the lifecycle than before, breaks things just as thoroughly.
Certain patterns cause damage almost every time they're tried. Reusing an event name to mean something new. Removing a field because "no one uses it," when someone, somewhere, always does. Using timestamps as a stand-in for version numbers, which is unclear to read and worse to maintain. Publishing different shapes of the same event with no version field attached, which makes debugging close to impossible. Forcing every consumer to upgrade in lockstep, which destroys the autonomy that made separate consumers workable in the first place.
The shape-versus-meaning gap is where teams get caught off guard most often. Schema validation passes clean. The types match, the required fields are there. But the behavioral meaning underneath has changed, and only a documented contract, one that states what a field is actually supposed to represent, catches that kind of break before it reaches production.
The Expand → Migrate → Contract pattern as the default migration path
The safest general-purpose path for evolving a schema runs in three deliberate phases.
Phase one is Expand: add the new schema alongside the old one. Both versions exist at the same time, and no consumer is forced to change anything yet. Adding phone_number as a new optional column, for instance, lets old and new producers both keep working without conflict.
Phase two is Migrate: update downstream consumers to the new schema one at a time, watching closely to confirm each one has actually made the switch before moving to the next. This phase takes patience, and that patience is the point. Rushing ahead to phase three before every consumer has genuinely migrated is a reliable source of production breakage in migrations that otherwise looked well-planned on paper.
Phase three is Contract: once every consumer is confirmed on the new schema, make the new fields required and remove whatever was deprecated.
Mobile products add a wrinkle worth naming directly. Old versions of an app can sit on user devices for a long stretch after a new release ships, still producing the old event shape the whole time. The Expand phase has to account for that reality, which makes jumping to Contract especially risky in a mobile context, since some meaningful share of the install base may still be emitting the old schema long after the team has moved on. Hard breaks, renaming a field, redefining what an event means, changing a type, deserve a formal deprecation window and a coordinated change program, not a quick pull request. That window is what gives teams time to document downstream impact, test replacements, and update dependent dashboards before the old contract gets pulled out from under them.
Versioning strategies: embedding version information in names, payloads, and topics
Once a breaking change is unavoidable, the version has to live somewhere. There are three common places to put it, and each comes with its own trade-offs.
Putting the version in the event name itself (user.created.v1, for instance) makes routing simple and lets consumers opt into a specific version on purpose. Older consumers stay untouched by whatever the new version does. The cost shows up on the publisher's side: backward compatibility means duplicating events, publishing v1 and v2 at the same time, which opens up a messy failure case where v1 succeeds, v2 fails, and there's no clean way to roll either one back on its own. This approach earns its keep when breaking changes are simply unavoidable and consumers are known to evolve at different speeds.
Putting the version in the payload, a schema_version field carried on the event itself, lets consumers branch their handling logic based on that field, and makes non-breaking changes noticeably easier to introduce. But backward compatibility still often demands duplicated events if older consumers can't be allowed to break, consumer code gets messier while it juggles multiple versions at once, and without an explicit opt-in filter a consumer can end up silently receiving a version it has no idea how to process. This tends to work best when the same team owns both the publishing and consuming sides and can coordinate updates without a lot of friction. Some platforms recommend treating events as versioned artifacts outright, using a schema_version property, an explicit deprecation timeline, and tooling that maps old events onto new schemas during the transition window.
Putting the version in the topic or stream itself (user-created-v1, user-created-v2, as separate Kafka topics or SNS streams) gives the cleanest infrastructure-level isolation. Consumers subscribe to exactly the version they expect and nothing else. The trade-off is topic sprawl, harder aggregation and replay across versions, and, again, duplicated events on the publisher's side. This fits best when the broker in use already supports large numbers of topics and consumers genuinely must never see more than one version of the same event.
All three strategies share the same underlying weakness: the overhead of backward compatibility lands squarely on the publisher, who ends up emitting multiple versions of the same event at once, which introduces its own new set of failure modes. That points to the two questions that actually matter more than picking a strategy: can breaking changes be avoided in the first place, and can backward compatibility be handled without turning every publisher into a small compatibility layer.
Upcasting and the no-breaking-changes discipline as alternatives to parallel publishing
Upcasting takes a different approach entirely: transform old event versions into the current version at read time, so consumers only ever have to handle one schema shape, ever.
The mechanism is a chain of small transformers, each one moving an event from version N to version N+1, stacked so any historical event, no matter how old, can be walked forward automatically to the current shape. A representative chain: OrderPlacedV1 becomes V2 by adding a currency field and splitting a single amount into subtotal and tax (defaulting legacy events to USD), then V2 becomes V3 by adding a customerId field (defaulting legacy events to a LEGACY_UNKNOWN placeholder). Consumers never see V1 or V2 directly. They only ever see the current version, because the upcasting layer did the work first. The cost is that someone has to keep writing and testing upcaster logic every time the schema moves forward again.
Downcasting runs the same idea in reverse: transforming new events down into a shape older consumers can still read, useful when a newer producer has to coexist with consumers that can't be updated yet. Explicit deserialization, handling the payload transformation directly in code rather than relying on serializer magic, is the most transparent option when payload shapes have diverged enough that automatic mapping gets unreliable.
A stricter alternative sidesteps all of this: never publish a breaking change at all. Always add new fields, never remove or rename an existing one, never change an existing field's data type. This is the no-breaking-changes discipline, and it removes the dual-publish problem structurally rather than managing around it. It demands real organizational discipline, plus a broker or schema registry that enforces compatible-only changes rather than just suggesting them. It's arguably the most stable long-term approach for a team that can actually commit to additive-only evolution and hold the line on it.
Occasionally a team wants to rewrite history outright, replacing a messy accumulated event log with one clean, current schema. That's possible: read the old stream, write to a new stream or a new store, and cut over once the new store has caught up. It's a significant, deliberate operation though, not something to reach for as a routine versioning step.
Aggregates with short lifespans (days, not years) shrink the whole problem, a structural trick worth knowing. Ship a version that supports both old and new schema for a few days, then drop support for the old one in the very next deployment. The backward-compatibility window stays short almost by construction.
Schema registry and broker enforcement as automated guardrails
A schema registry exists to catch breaking changes before they ever reach a running system. When a producer tries to publish an updated schema, the registry checks it against compatibility rules, fields not removed, types not changed, and simply rejects any schema that violates them before it goes anywhere near production traffic.
That's an early feedback loop worth having: a producer gets told no at registration time, not after some consumer downstream has already ingested a bad event and started acting on it. At the ingestion layer, the broker validates each incoming event's payload against the registered schema, and invalid messages can be rejected outright, quarantined, or routed to a dead-letter queue, so only valid events reach the consumers on the other side.
Schema registries typically offer several compatibility modes, backward, forward, and full, each giving a different guarantee about what old and new consumers are allowed to read relative to each other. The serialization format underneath, Avro, Protobuf, JSON Schema, carries its own set of compatibility rules and tooling support, and the right pick depends on what's already in the stack and how strict the governance needs to be.
None of this replaces judgment, though. A registry enforces structure and types. It has no way to know whether an event is firing at the correct point in a user's lifecycle, or whether an enum value actually carries the business meaning someone assumes it does. It also adds a dependency, a live external service the whole pipeline now leans on. A schema registry is a guardrail, not a strategy. Teams still have to decide how they're going to version, migrate, and communicate change, and no amount of automated enforcement does that thinking for them.
Sources
- Event versioning strategies for event-driven architectures
- How to Version CDP Event Schemas Without Breaking Downstream Activation
- Simple patterns for events schema versioning - Event-Driven.io
- Event Schema Evolution: 4 Versioning Strategies, 1 That Quietly Breaks Consumers
- theburningmonk.com
- docs.solace.com
- medium.com
- dev.to


