Versioning Flag Configuration Schemas Safely
This how-to is part of Designing a Scalable Flag Taxonomy. Your flag configuration format needs a new field — a variant weight, a metadata attribute, a targeting operator — but older SDK clients still read the old shape, and a naive change breaks them mid-deploy. This how-to evolves the config schema safely: additive changes by default, an explicit schema version, dual-read compatibility across a deprecation window, so a schema change never takes down a client that has not upgraded yet.
The reason this is hard is that the config schema is a contract with no single owner. Unlike an API where the server controls the wire format and can version an endpoint, a flag config is read by every SDK client in your fleet — mobile apps you cannot force-update, batch jobs that run monthly, a sidecar that was last redeployed in Q1. The writer changes on one deploy; the readers change on hundreds of independent schedules you do not control. That asymmetry is why the only durable strategy is one where a change is safe regardless of which reader picks it up first — and additive-plus-dual-read is exactly that strategy, because it removes the coordination requirement entirely rather than trying to satisfy it.
Prerequisites
Step-by-Step Procedure
Step 1 — Add a schema version to every config
Stamp each config payload with its schema version so clients can branch on it and you can detect incompatibility explicitly rather than by parse failure.
{ "schemaVersion": 3, "key": "web.checkout.new-flow",
"variants": { "on": true, "off": false }, "rolloutPct": 25, "weights": [70, 30] }
Version the schema, not each individual flag — schemaVersion describes the shape of the envelope, so bumping it is a fleet-wide event that every reader can reason about with a single integer comparison. Keep it monotonic and never reuse a number, because a client that logs “saw schemaVersion 3” is the raw material for the deprecation decision in Step 4; if the number is ambiguous, that telemetry is worthless. A useful discipline is to treat an unknown future version as a warning rather than an error: a v3 client that meets a v4 config should log and keep resolving on the fields it recognizes, not refuse the payload — the version field is there to make you observant, not brittle.
Step 2 — Make changes additive by default
Add new fields; never rename or remove one that deployed clients read. An additive change is invisible to old clients and fully usable by new ones.
# migrate.py — additive: old fields stay, new field is optional
def to_v3(cfg_v2: dict) -> dict:
return { **cfg_v2, "schemaVersion": 3, "weights": derive_weights(cfg_v2) } # nothing removed
Additive has a subtler requirement than “don’t delete”: the new field must also be optional on the read path, because during the overlap your writer is emitting configs with the field and, if you ever roll the writer back, configs without it. A v3 client that treats weights as mandatory will crash on the first payload the rolled-back writer produces — you have simply moved the breakage from old clients to new ones. The other rule that trips people is defaulting: pick a default for the new field that reproduces the old behavior exactly, so a config that omits it and a config that carries the equivalent old field resolve to the same variant. If the derived default differs even slightly — a rounding step in derive_weights, a tie-break that lands the other way — you have introduced a silent behavior change dressed up as a schema addition, and it will surface as a bucketing discrepancy nobody can trace back to this deploy.
Step 3 — Dual-read across the version boundary
Have clients read the new field when present and fall back to the old field’s meaning when it is absent, so a single client build works against both schema versions during the overlap.
# read.py — dual-read: prefer new field, fall back to old
def resolve_weights(cfg: dict) -> list[int]:
if "weights" in cfg: # v3 client on v3 config
return cfg["weights"]
pct = cfg.get("rolloutPct", 0) # v3 client on older config
return [100 - pct, pct] # derive the old meaning
Dual-read is where the “prefer new, fall back to old” ordering earns its keep: a config that carries both fields — which is exactly what an additive writer produces — must resolve using the new field, because the old one is only there for readers that do not understand the new one. Get the precedence backwards and your v3 clients will keep honoring rolloutPct while you think you have migrated them to weights, and the two will drift the moment someone edits only the new field. Keep the fallback branch a pure translation of the old field’s meaning, with no new logic bolted on; the whole point is that it produces the byte-for-byte answer an old client would have produced, so the overlap window is genuinely behavior-neutral. Prune the fallback branch only in the same later release that removes the field it reads — never sooner — so the code that understands the old shape outlives the last config that carries it.
Step 4 — Retire the old field only after the window
Once every client reads the new field and enough time has passed, remove the old field in a later schema version — never in the same change that introduced the new one. “Enough time” is not a calendar value you guess; it is the moment your telemetry shows zero reads of the old field across every deployed client. Gate the removal on that signal explicitly — a query over the schema-version each client reports, or a counter incremented on the fallback branch — and treat a non-zero count as a hard block on the deprecation, not a nudge. The removal itself is cheap and reversible-in-planning but expensive-in-production if you are wrong: once a v4 config with the field stripped reaches a client still on the fallback path, that client silently returns the default, and the incident looks like a targeting bug rather than a schema one, which is why it costs hours to diagnose.
# deprecate.py — only after all clients are on v3+
def to_v4(cfg_v3: dict) -> dict:
c = dict(cfg_v3); c.pop("rolloutPct", None) # safe: no client reads it anymore
c["schemaVersion"] = 4
return c
Verification
Feed both the old and new config shapes to the current client and assert it resolves identically, then feed the new shape to an old client build and assert it still works. The second test is the one teams skip and the one that catches real outages: keep a pinned build of the oldest client version you still support in CI and run the new config shape through it on every schema change, because “we’re sure old clients ignore it” is an assumption, and this test converts it into a checked fact. Make the equivalence assertion exact rather than approximate — the same variant and the same bucket, not merely the same on/off answer — since a schema change that shifts which users land in which variant is still a break even when the flag reports as enabled for both shapes.
# schema.test.py
def test_dual_read_equivalence():
assert resolve_weights({"rolloutPct": 30}) == [70, 30] # old shape
assert resolve_weights({"weights": [70, 30]}) == [70, 30] # new shape -> same result
def test_old_client_ignores_new_field(old_client):
assert old_client.evaluate(cfg_v3) == old_client.evaluate(cfg_v2) # new field ignored, same answer
Gotchas & Edge Cases
- A strict parser turns additive into breaking. If clients deserialize into a struct that rejects unknown keys, a new field crashes them. Configure the parser to ignore unknown fields (or use a format designed for it) before relying on additive evolution. This is a per-language footgun: Go’s
json.Decoderignores unknowns by default but flips to strict the moment someone callsDisallowUnknownFields(), Jackson rejects unknowns unlessFAIL_ON_UNKNOWN_PROPERTIESis disabled, and a Pydantic model withextra="forbid"will raise. Audit the actual decoder config in each client, not the language’s reputation. - Reusing a field is a semantic break in disguise. Changing what an existing field means — even without renaming it — breaks old clients that read the old meaning. Add a new field for the new meaning instead of overloading an old one. The classic trap is a units or scale change —
rolloutPctgoing from 0–100 to 0–1, or a timeout moving from seconds to milliseconds — which passes every type check and every additive test while quietly resolving every old client wrong by three orders of magnitude. - Do not remove until client count on the old field is zero. Instrument which schema version each client reads and confirm no client still depends on the old field before removing it. Guessing the window and removing early is exactly the outage additive versioning prevents.
- Nullable and absent are not the same thing. A field present with value
nulland a field absent entirely can travel different code paths in the reader —"weights" in cfgis true for an explicitnullbut the value is unusable. Decide which one “not set” means and normalize it at the edge, or a config editor that writesnullinstead of omitting the key will slip past your dual-read fallback. - Config defaults and code defaults must agree. When a client reads a config that omits a field, it applies a default baked into the client build; when the control plane renders a config, it may apply a different default. If those two defaults ever diverge, the same logical flag resolves differently depending on whether the field was serialized — keep a single source of truth for defaults and assert it in the dual-read test.
Troubleshooting & FAQ
An SDK client crashed after we added a field we thought was harmless. Why?
Its deserializer rejects unknown fields, so an “additive” change was breaking for that client. Either make the client tolerant of unknown fields or, if you cannot update it in time, hold the schema change until it is upgraded. Additive is only safe when the reader ignores what it does not know.
How long should the deprecation window be?
Long enough that every client reading the config has upgraded — driven by your slowest-updating client, not the average. Instrument the schema version each client reports and keep the old field until that count reaches zero, rather than picking a fixed calendar duration.
Is protobuf safer than JSON for this?
Protobuf’s field numbers and reserved tags make additive changes and safe removal explicit, which helps. But JSON with an ignore-unknown-fields parser achieves the same additive safety. The format matters less than the discipline: version the schema, add before removing, and dual-read across the gap.
Do I need a schema version if all my changes are additive?
Yes, even though additive changes are safe without it. The version field is not what makes the change safe — it is what makes the change observable. Without a version stamp you cannot answer “have all clients migrated off the old field yet?”, which is the exact question Step 4 depends on, so you can never confidently remove anything. Additive-only evolution without a version leaves you unable to ever clean up.
Should the control plane store every historical schema version or migrate old configs forward?
Migrate forward and store one canonical shape. Keeping raw historical shapes in your config store means every reader has to understand every version that ever existed, which is the compatibility burden you were trying to avoid. Instead, run configs through an upgrade function on write so the store always holds the current schema, and let dual-read on the client absorb the gap between the stored shape and what an un-upgraded client expects. The store stays simple; the temporary complexity lives in the readers, where it can be deleted on a schedule.
How does this interact with a client-side cache or a persisted last-known config?
It extends your deprecation window in a way that is easy to forget. If a client persists the last config it fetched — for offline start-up or bootstrap resilience — then a config written weeks ago can be read long after you thought that shape was retired. Treat cached configs as another “old client” for deprecation purposes: the field is only truly dead once no live reader and no persisted cache still carries it, which usually means keeping the fallback branch one cache-lifetime longer than the fleet-migration signal alone would suggest.
What about targeting rules or operators — can I version those additively too?
Mostly, with one caveat. Adding a new operator is additive as long as old clients that meet an unknown operator fail closed to a safe default rather than crashing or matching by accident — an unrecognized semver-gt on a v2 client should evaluate to “rule did not match” and fall through, not throw. Encode that unknown-operator behavior deliberately, because a rule engine that treats an unfamiliar operator as a match is the operator-level version of the strict-parser bug, and it hands unintended users the new variant.