Migrating a Spatial Stream Between Schema Versions
Deploy readers that understand both versions first, translate every event into one internal representation at the edge, then cut producers over and wait for the per-version counter to reach zero — the old branch cannot be deleted at the deploy, only after the topic’s full retention has passed.
This guide sits under Schema Evolution & Versioning for Spatial Events, within Core Event Fundamentals & Architecture. Where Adding a Field to a Live Spatial Event Schema covers the compatible case, this one covers the change that genuinely breaks: a renamed geometry member, a coordinate order flip, a CRS that is no longer implicit.
When to use this pattern
- The change is not backward compatible — a field removed or renamed, a type changed, coordinates reordered, or a unit redefined.
- The stream has consumers you do not deploy, so there is no moment when everything switches at once.
- Events already in the broker were written under the old contract and will still be read after the cutover.
If the change is purely additive, this is more machinery than the problem needs.
Why an atomic cutover does not exist
The broker holds events written under the old contract. Even if every producer and consumer restarted in the same instant, the backlog does not. A consumer starting at its committed offset reads old events for as long as the lag lasts; a consumer resetting to earliest reads them for as long as the topic retains them.
Complete runnable implementation
The shape that works is a translator at the edge and one internal representation behind it. Business logic must never see a version number; the moment it does, every future migration touches every handler.
from dataclasses import dataclass
from datetime import datetime
from prometheus_client import Counter
from pyproj import Transformer
from shapely.geometry import mapping, shape
from shapely.ops import transform as shapely_transform
SEEN = Counter("spatial_event_schema_version_total", "Events by version", ("version",))
# v1 emitted local grid coordinates with an implicit CRS; v2 is explicit and
# always EPSG:4326. That is the breaking part: a v1 payload read as v2 is
# silently wrong rather than invalid.
_V1_TO_WGS84 = Transformer.from_crs("EPSG:25833", "EPSG:4326", always_xy=True)
@dataclass(slots=True, frozen=True)
class FeatureEvent:
"""The one representation the rest of the system knows about."""
feature_id: str
occurred_at: datetime
geometry: dict # GeoJSON, always EPSG:4326
source_version: int # kept for metrics only — never branched on
class UnknownSchemaVersion(Exception):
"""An event whose version this build has never heard of."""
def _from_v1(body: dict) -> FeatureEvent:
geom = shape(body["geom"]) # renamed to "geometry" in v2
geom = shapely_transform(_V1_TO_WGS84.transform, geom)
return FeatureEvent(
feature_id=str(body["fid"]), # renamed to "feature_id"
occurred_at=datetime.fromisoformat(body["ts"]),
geometry=mapping(geom),
source_version=1,
)
def _from_v2(body: dict) -> FeatureEvent:
if body.get("crs") != "EPSG:4326":
raise ValueError(f"v2 requires EPSG:4326, got {body.get('crs')!r}")
return FeatureEvent(
feature_id=body["feature_id"],
occurred_at=datetime.fromisoformat(body["occurred_at"]),
geometry=body["geometry"],
source_version=2,
)
_READERS = {1: _from_v1, 2: _from_v2}
def translate(headers: dict[str, str], body: dict) -> FeatureEvent:
"""Read the version from the header, fall back to the payload copy.
An unknown version must raise rather than default. Defaulting to the
newest reader means a v3 event produced by a service deployed ahead of
this one is parsed as v2, and the failure is a wrong geometry rather
than an error.
"""
version = int(headers.get("schema-version") or body.get("schema_version", 0))
SEEN.labels(version=str(version)).inc()
reader = _READERS.get(version)
if reader is None:
raise UnknownSchemaVersion(f"no reader for schema version {version}")
return reader(body)
The UnknownSchemaVersion path is the one people remove because it never fires in testing. It fires the first time a service is deployed ahead of its consumers, which during a migration is exactly the situation you are in.
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
schema-version header |
str |
Must be present on every produced event; readable without deserialising | — |
schema_version body field |
int |
The copy that survives archival and transport changes | — |
_READERS |
dict[int, Callable] |
One entry per version still reachable in the log or an archive | {1, 2} |
always_xy |
bool |
Must be True on every Transformer, or the axis order flips silently |
True |
| Overlap duration | timedelta |
≥ topic retention, measured from the last v1 event produced | 7 days |
UnknownSchemaVersion |
exception | Must raise, never default to the newest reader | — |
Gotchas and spatial edge cases
-
A coordinate-order change is not detectable by validation. If v1 emitted
[lat, lon]and v2 emits[lon, lat]per RFC 7946, a v1 payload read by the v2 reader produces a point in a different hemisphere that is a perfectly valid geometry. The version header is the only thing standing between you and a fleet of features relocated to the Indian Ocean, which is why an unknown version must raise rather than guess. -
The reprojection in
_from_v1is not free and not exact. Transforming every historical event on read adds latency proportional to vertex count and introduces the sub-millimetre differences described in CRS Normalization Strategies. If a content hash is computed downstream from the translated geometry, the same logical event hashes differently depending on which reader produced it — round to a fixed precision after translating, not before. -
Dual-writing to two topics splits ordering. Publishing v1 to the old topic and v2 to the new one gives a clean break, but two events for the same feature now live on different topics with no defined order between them. A consumer reading both can apply an old edit after a new one, and the conflict machinery in Conflict Resolution Strategies becomes load-bearing where it previously was not.
-
The per-version counter reaching zero is necessary, not sufficient. It says no v1 event has been read recently. It does not say none remains in the log, and an offset reset will find them. Wait out the retention window, and check any dead-letter archive separately — replaying a v1 event from a six-month-old archive is exactly when the deleted reader is missed.
-
Compaction preserves old versions indefinitely. On a compacted topic, the last event for a feature that stopped changing before the cutover is retained forever. There is no retention window to wait out; the v1 reader is permanent unless the topic is rewritten.
Verification
import pytest
from shapely.geometry import shape
V1 = {"fid": 4471, "ts": "2026-08-08T09:14:00+00:00",
"geom": {"type": "Point", "coordinates": [389876.5, 5819432.1]}}
V2 = {"feature_id": "4471", "occurred_at": "2026-08-08T09:14:00+00:00",
"crs": "EPSG:4326", "geometry": {"type": "Point", "coordinates": [13.4049, 52.5200]}}
def test_both_versions_land_in_the_same_place():
"""The property the migration promises: same event, same geometry."""
a = translate({"schema-version": "1"}, V1)
b = translate({"schema-version": "2"}, V2)
assert a.feature_id == b.feature_id
assert shape(a.geometry).distance(shape(b.geometry)) < 1e-4 # ~11 m
def test_unknown_version_raises_rather_than_guessing():
"""A v3 event must not be parsed by the v2 reader."""
with pytest.raises(UnknownSchemaVersion):
translate({"schema-version": "3"}, V2)
def test_v2_without_explicit_crs_is_rejected():
"""The implicit CRS is exactly what v2 exists to remove."""
with pytest.raises(ValueError):
translate({"schema-version": "2"}, {**V2, "crs": None})
The first test is the one that catches an axis-order mistake in the transformer, because a flipped always_xy moves the translated point thousands of kilometres — well outside the tolerance — while every other test still passes.
Related
- Schema Evolution & Versioning for Spatial Events — the topic this guide belongs to
- Validating Schema Compatibility in CI — catching the breaking change before it reaches a topic at all
- Adding a Field to a Live Spatial Event Schema — the compatible case, and why it still needs care
- CRS Normalization Strategies — where the reprojection in the v1 reader belongs long-term