Adding a Field to a Live Spatial Event Schema
Add the field to the idempotency hash’s exclusion list before you add it to the schema — otherwise the same logical event hashes differently on either side of the rollout, deduplication stops matching, and every redelivery during the changeover is written twice.
This guide sits under Schema Evolution & Versioning for Spatial Events, within Core Event Fundamentals & Architecture. It assumes the deterministic key scheme described in Event Key Generation for Spatial Data.
When to use this pattern
- You are adding an optional field — a confidence score, a source identifier, a sensor model — to a stream that is already in production and already deduplicated.
- Your idempotency or content hash is currently computed over the serialised payload rather than over a named subset of it.
- Consumers exist that you do not control, so the change has to be safe for readers that will never be updated.
If the stream has no deduplication and no content hashing, adding an optional field really is trivial, and this guide is about the case where it is not.
Why the hash is the problem, not the field
The field itself is harmless. Old consumers ignore it, new consumers read it, and every payload validates on both sides. What breaks is anything downstream that treated the payload’s bytes as the event’s identity.
Complete runnable implementation
import hashlib
import json
import math
from typing import Any
from shapely.geometry import mapping, shape
from shapely.ops import transform
# The identity of a spatial event: which feature, what shape, when observed.
# Anything describing the observation rather than naming it stays out.
IDENTITY_FIELDS = ("feature_id", "geometry", "occurred_at")
PRECISION = 6 # decimal places; part of the schema, see the parent topic
def _round_geometry(geometry: dict[str, Any]) -> dict[str, Any]:
"""Round coordinates so float noise cannot move the digest."""
geom = shape(geometry)
rounded = transform(
lambda *coords: tuple(round(c, PRECISION) for c in coords), geom
)
return mapping(rounded)
def identity_key(event: dict[str, Any]) -> str:
"""Derive the idempotency key from named fields only.
Adding, removing or reordering any field outside IDENTITY_FIELDS cannot
change this value — which is exactly the property that makes a schema
addition safe to deploy against a live deduplication store.
"""
identity = {}
for name in IDENTITY_FIELDS:
value = event[name]
identity[name] = _round_geometry(value) if name == "geometry" else value
canonical = json.dumps(
identity, sort_keys=True, separators=(",", ":"), ensure_ascii=True
)
return hashlib.sha256(canonical.encode()).hexdigest()[:32]
def confidence_of(event: dict[str, Any]) -> float:
"""Read the new field, preserving the difference between absent and zero.
An event produced before the field existed did not measure confidence.
Returning 0.0 would assert the reading is certainly wrong, which is a
different — and false — statement.
"""
if "confidence" not in event:
return math.nan
return float(event["confidence"])
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
IDENTITY_FIELDS |
tuple[str, ...] |
Must name identity, not description; changing it is itself a breaking change | ("feature_id", "geometry", "occurred_at") |
PRECISION |
int |
Decimal places in EPSG:4326; 6 ≈ 11 cm. Changing it moves every key | 6 |
sort_keys |
bool |
Must be True, or key insertion order leaks into the digest |
True |
separators |
tuple[str, str] |
Must be (",", ":") so whitespace cannot vary |
— |
ensure_ascii |
bool |
True keeps non-ASCII place names encoded identically across producers |
True |
Gotchas and spatial edge cases
-
Changing
IDENTITY_FIELDSis a breaking change, not a refactor. Every key in the deduplication store was computed under the old list. Adding or removing an identity field invalidates all of them at once, so it needs the same overlap treatment as any other schema version bump — and, unlike a payload field addition, it cannot be made invisible. -
The geometry must be normalised before it enters the key. If the added field’s rollout coincides with a producer that emits different ring winding or a different CRS, you will see the deduplication failure and blame the new field. Normalise projection, winding and precision inside
identity_keyso the key depends on the shape rather than on its encoding. -
NaNdoes not compare equal to itself. Usingmath.nanto mean “not measured” is correct for storage and wrong for equality tests:nan == nanisFalse, so a naive comparison treats two unmeasured events as different. Test withmath.isnan(), or carry a separateconfidence_measured: boolif the value flows into comparison logic. -
A field added inside
geometryis not optional at all. GeoJSON geometry objects have a fixed member set; adding a vendor field inside one produces a payload many strict parsers reject, and it will be included by any geometry-aware hashing. Extra fields belong in the envelope, alongside the routing keys. -
Backfilling the new field changes historical keys. If you run a job to populate
confidenceon stored events, and anything recomputes keys from stored records, those records now hash differently from the live stream. Backfill the value but never recompute the key — the key was assigned at ingest and is part of the event’s identity, as Replaying Dead-Letter Spatial Events Safely describes.
Verification
import math
import pytest
def test_added_field_does_not_move_the_key():
"""The property the whole change depends on."""
before = {"feature_id": 4471, "occurred_at": "2026-08-08T09:14:00Z",
"geometry": {"type": "Point", "coordinates": [13.4049547, 52.5200087]}}
after = before | {"confidence": 0.87}
assert identity_key(before) == identity_key(after)
def test_absent_confidence_is_not_zero():
"""A pre-rollout event must not claim a measurement it never had."""
assert math.isnan(confidence_of({"feature_id": 1}))
assert confidence_of({"feature_id": 1, "confidence": 0.0}) == 0.0
def test_identity_field_change_is_detected():
"""The gate's negative case: the key must be sensitive to identity.
A key that ignored everything would satisfy the first test perfectly, so
this asserts the digest still moves when the geometry genuinely changes.
"""
a = {"feature_id": 4471, "occurred_at": "2026-08-08T09:14:00Z",
"geometry": {"type": "Point", "coordinates": [13.4049547, 52.5200087]}}
b = a | {"geometry": {"type": "Point", "coordinates": [13.5, 52.5200087]}}
assert identity_key(a) != identity_key(b)
The third test is the one that earns its place. Without it, a key function that hashed a constant would pass the first two and destroy deduplication entirely.
FAQ
Why does adding an optional field break deduplication?
Only if the idempotency key is derived from the whole payload. A canonical serialisation of the full event includes the new field, so the same logical event hashes differently before and after the producer starts sending it. The deduplication store holds the old hashes, the new events produce new ones, nothing matches, and every redelivery across the rollout window is treated as novel and written again. Hashing over an explicit list of identity fields makes the addition invisible to the key.
Should a new numeric field default to zero for old events?
Almost never. Zero is a measurement; absence is the lack of one, and collapsing the second into the first invents data. A confidence of 0.0 asserts the reading is certainly wrong, while a missing confidence says nobody measured it — a consumer filtering on confidence below 0.2 will discard every historical event if you default to zero. Use NaN, None, or a separate presence flag so the difference survives.
Do I need a new schema version just to add a field?
Yes, even though the change is structurally compatible. The version is what lets a consumer know which contract it received, so it can tell a genuinely absent value from one the producer simply had not started sending yet. Without it, an old event and a new event that happens to omit an optional field are indistinguishable, and any logic that treats absence as meaningful has no way to interpret it.
Related
- Schema Evolution & Versioning for Spatial Events — the parent topic, including why spatial schemas break silently more often than structural ones
- Event Key Generation for Spatial Data — the canonicalisation pipeline the identity key depends on
- Core Event Fundamentals & Architecture — the section, and the four-layer envelope the new field belongs in