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.

Additive schema evolution with dual read A new schema version adds fields without removing old ones; new clients read the new fields while old clients ignore them, and both resolve correctly during the overlap window. config (schema v3) key, variants (v1 fields) rollout % (v2 field) weights[] (v3 field, new) new client reads weights[] old client ignores weights[], still works
Additive evolution means the new field is invisible to old clients — they keep resolving on the fields they know, so the overlap window is safe by construction.

Prerequisites

Schema-versioning prerequisites A schema with a version field, knowledge of client versions in production, and a format that tolerates unknown fields. Version field in the schema Client versions what's deployed Tolerant format unknown fields ok
A tolerant serialization format is what makes additive changes free — a parser that rejects unknown fields turns every new field into a breaking change.

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
The four schema-versioning steps Add a schema version, make changes additive, dual-read across the version boundary, and retire the old field only after the deprecation window. 1 Version field stamp it 2 Additive never remove 3 Dual-read both versions 4 Retire later after window
The discipline is temporal separation: add in one version, remove in a much later one, with dual-read spanning the gap so no client is ever caught between them.

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
Both shapes resolve identically The old config shape and the new config shape both resolve to the same weights, and an old client ignores the new field and returns the same answer. old shape [70,30] new shape [70,30] equal no break
Identical resolution across both shapes is the guarantee — the schema changed but every client, old and new, computes the same variant.

Gotchas & Edge Cases

Three schema-versioning edge cases A strict parser rejecting unknown fields, a semantic change disguised as additive, and removing the old field before all clients migrated. Strict parser rejects unknown additive breaks Hidden semantic reused field, new meaning → bug Early removal old client left wait for zero
The hidden-semantic case is the trap that looks additive but is not — reusing an existing field with a new meaning breaks old clients that read the old meaning.

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.