Schema Evolution & Versioning

During a rolling deployment, old and new pods run at the same time against the same injected environment. If the new release renames DB_DSN to DATABASE_URL, the old pods break the instant you switch the variable — unless the schema accepts both names. This page evolves a settings schema without ever needing a downtime window.

Safe evolution is what keeps the settings model stable as requirements change, the final discipline in the type-safe validation section.

The core insight is that a configuration schema is a contract with every running instance, and during a rolling deploy that contract is held by two versions of your code at once. For a window that can last seconds or minutes, the old pods and the new pods read the same injected environment through different schemas. Any change that makes those two schemas disagree about what a valid configuration looks like — a renamed variable, a removed field, a tightened constraint — is a change that can break whichever version is on the wrong side of it. Zero-downtime evolution means never letting the two schemas conflict: every change is made additive first, so both versions validate throughout the transition, and subtractive only later, once every running instance has moved to the new shape.

Three kinds of change recur, and each has a safe recipe. A rename uses AliasChoices to accept both the old and new variable names during an overlap window. A removal keeps the field as an ignored, optional one until every environment stops setting it, then deletes it. And a default or constraint change is treated as a behavioural change — announced, rolled out deliberately, and never slipped in silently, because it alters what a process actually does even for the environments that set nothing at all. The rest of this page is those recipes and the discipline that ties them together.

It helps to see why the naive approach fails, because the failure is not obvious until you picture the rolling deploy. Suppose you rename DB_DSN to DATABASE_URL in one commit — change the field, change the manifest — and deploy. The orchestrator brings up new pods and drains old ones over some minutes. During that window, the injected environment provides DATABASE_URL (the manifest changed), but the old pods still running the previous image are looking for DB_DSN, which is now absent. Those old pods raise ValidationError and crash, and until the rollout finishes, a fraction of your traffic hits dying pods. The rename itself was correct; only the sequencing was wrong. Every recipe on this page exists to make sequencing safe by keeping both schemas valid for the whole overlap.

Secure implementation

# config/versioned.py
import warnings
from pydantic import AliasChoices, Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(extra="forbid", populate_by_name=True)

    # Accept the new name and the legacy name during the migration window.
    database_url: str = Field(
        validation_alias=AliasChoices("DATABASE_URL", "DB_DSN"),
    )

    # Deprecated: still accepted, but warns until the removal release.
    legacy_timeout: int | None = None

    @model_validator(mode="after")
    def warn_deprecated(self) -> "Settings":
        if self.legacy_timeout is not None:
            warnings.warn(
                "legacy_timeout is deprecated; use REQUEST_TIMEOUT (removed in v3).",
                DeprecationWarning,
                stacklevel=2,
            )
        return self

AliasChoices lets both old and new pods validate during the overlap. The deprecation warning gives operators a removal deadline without breaking anything today.

Two mechanisms in that code do the work. AliasChoices("DATABASE_URL", "DB_DSN") in a field’s validation_alias tells pydantic to accept either variable name for database_url — so a pod reading DB_DSN (the old name) and a pod reading DATABASE_URL (the new name) both validate cleanly against the same single schema. That is what makes a rename survivable during a rolling deploy: for the duration of the overlap window, both names are valid, so neither the old nor the new code is ever left looking for a name its environment does not currently provide. The order matters — AliasChoices takes the first name that is present — so put the new name first once you begin migrating environments over. There is a symmetry worth noticing in how you order the names across the migration’s phases. When you first introduce the alias but environments still set the old name, listing either order works because only the old name is present. Once you begin flipping environments to the new name, putting the new name first means that any environment that has migrated uses the new value while any that has not still falls back to the old — a clean, predictable resolution. The one situation to avoid is leaving both names set with different values across the fleet, because then which one wins depends on the alias order and it is easy to lose track; the cleanest migration sets exactly one name in each environment and flips it atomically per environment, so at no point does a single environment carry two conflicting values for the same setting.

The deprecation path uses a model_validator that emits a DeprecationWarning when a soft-removed field is still set. This is the gentler cousin of a hard removal: the field stays optional and functional, but every environment that still supplies it gets a warning naming the replacement and the removal release. The stacklevel=2 in the warning call is a small but useful detail — it makes the warning point at the code that constructed the settings rather than at the validator’s internals, so the message shows up attributed to something an operator or developer can act on. A deprecation warning is only as useful as its visibility and its message, so name the replacement explicitly, name the removal release, and make sure the warning is surfaced rather than swallowed. That gives operators a deadline and a migration path without breaking anything the day the deprecation ships. populate_by_name=True rounds it out by letting the field be populated by its Python name as well as its alias, which keeps programmatic construction and tests working while an alias is in place.

AliasChoices lets old and new pods validate the same environment During a rolling deploy, an old pod reading DB_DSN and a new pod reading DATABASE_URL both validate against one schema because AliasChoices accepts either name during the overlap window. old pod DB_DSN new pod DATABASE_URL AliasChoices accepts both names both validate no downtime overlap window — both names live
During the overlap, one schema accepts both names, so old and new pods validate the same injected environment.

Configuration reference

Tool Purpose Removal step
AliasChoices(new, old) Accept both names Drop old after cutover
populate_by_name=True Allow field name and alias Keep while aliasing
Optional deprecated field Soft-remove a setting Delete after warning window
model_validator warning Signal deprecation Promote to error, then remove

Each tool in that table maps to a phase of a change’s life, and reading them as a sequence is the point. AliasChoices opens a rename by accepting both names; populate_by_name keeps code and tests working alongside the alias; an optional deprecated field soft-removes a setting without breaking anyone who still sets it; and a model_validator warning signals the deprecation to operators and can later be promoted to a hard error before the field is finally deleted from the schema. The “removal step” column is the reminder that every one of these is temporary scaffolding — the alias, the optional field, and the warning all exist to be removed once the migration completes and every environment has caught up. Leaving them in place forever is how a schema accretes a confusing layer of dead aliases and permanently-deprecated fields that nobody dares touch. A discipline that helps is to file the removal at the same time you ship the addition: when you open a rename, immediately create a ticket or changelog entry for removing the alias in a specific future release, so the cleanup is scheduled rather than forgotten and left to rot in the codebase. The overlap window is meant to be temporary, and treating its closure as planned work — not an afterthought that happens if someone remembers — is what keeps the schema clean over time.

Schema-evolution tools mapped to a change's lifecycle AliasChoices opens a rename, populate_by_name keeps code working, an optional deprecated field soft-removes a setting, and a model_validator warning signals deprecation before final removal. AliasChoicesopen the rename populate_by_namecode + tests keep working optional fieldsoft-remove a setting warning → errorthen delete a change's lifecycle, left to right every tool is temporary scaffolding — remove it once the migration completes
Each tool opens or closes a phase of a change; all are scaffolding meant to be removed after cutover.

Adding a field is the easy case

Not every schema change is fraught — adding a new field is almost always safe, and understanding why sharpens the intuition for the harder cases. A new field with a default is purely additive: old pods do not know about it and do not need to, new pods read it or fall back to the default, and no environment is required to set anything. Ship it whenever you like. The only care needed is that a new required field (no default) is a breaking change in disguise — it demands that every environment provide the variable before the new code can boot — so introduce required fields with a default first, let environments populate them at their own pace, and tighten to required only once every environment reliably sets the variable. The general rule falls out cleanly: additions with a safe default are free, and anything that demands something new from the environment is subject to the same overlap discipline as a removal.

Tightening a constraint is a subtractive change

A change that looks additive but behaves subtractively is tightening a constraint — turning a free-form region: str into region: Literal[...], or adding Field(ge=1) to a previously unbounded integer. It is subtractive because it shrinks the set of values the schema accepts, so a value that was valid yesterday can be rejected today even though nothing about that value changed — only the schema’s tolerance for it did. If an environment carries a value your tightened constraint now forbids, that environment fails to boot on the new code. The safe approach mirrors the removal discipline: ship the stricter schema to a canary, observe the values real environments actually carry, widen the constraint to cover any legitimate stragglers you did not anticipate, and only then roll it to the fleet with confidence. Tightening is worth doing — it is how a loosely-typed field becomes a properly validated one — but it is a change to the contract, not a free refinement, and it deserves the staged rollout every contract change gets.

Deployment parity: local to production

  1. Local dev — both names work; developers are unaffected mid-migration.
  2. CI — assert the model validates with the old name and the new name during the window.
  3. Staging — switch the injected variable to the new name; old-name pods still pass.
  4. Production — roll out, confirm no deprecation warnings fire, then remove the alias in the next release.

The sequence is deliberately additive-then-subtractive, and skipping a step is where downtime creeps in. The alias is added first, so both names validate everywhere before any environment switches. CI then proves the overlap actually works by constructing the model with the old name and the new name — a test that fails the moment someone accidentally breaks one of them. Only then do you flip the injected variable, environment by environment, while the old-name pods keep validating through the alias. Removal of the alias is a separate, later release, made only after production has confirmed no code path and no environment still uses the old name. Each step preserves the invariant that both schemas agree, which is exactly why no request ever hits a pod that cannot read its configuration.

The CI assertion in step two deserves emphasis because it is what makes the whole sequence trustworthy rather than hopeful. It is easy to add an alias and believe both names work; it is another thing to prove it. A test that constructs Settings() once with DB_DSN set and once with DATABASE_URL set, asserting both produce the same validated database_url, turns the overlap from an assumption into a checked fact — and it fails loudly if a later refactor accidentally drops the alias or reorders the names. Keep that test for the entire duration of the overlap window, and delete it in the same release that removes the alias, so the test’s lifetime tracks the migration’s exactly. Without it, the first sign that the overlap was broken is an old pod crashing in production, which is precisely the outcome the alias was meant to prevent.

The other subtlety in the sequence is that “confirm no environment still uses the old name” before removal is an active step, not a passive assumption. Grep your manifests and injected-variable definitions for the old name, check the deprecation-warning logs (or the CI failures if you enabled PYTHONWARNINGS=error), and only when every one of those sources is clean do you ship the removal that deletes the field. Removing the alias while a single forgotten environment still sets the old variable turns that environment’s next deploy into a ValidationError — the removal is safe only once you have positively verified the old name is gone everywhere.

A zero-downtime rename across four deployment stages Add the alias so both names work locally, prove the overlap in CI, switch the injected variable in staging while old-name pods still pass, then roll out and remove the alias in a later release. alias window — both names accepted 1 2 3 4 add aliasCI proves overlapswitch variableremove alias local: both workold + new passold pods still oklater release DB_DSN → DATABASE_URL, no downtime
Add the alias, prove it, switch the variable, and remove the alias only in a later release.

Security boundaries & guardrails

  • Never rename and remove in the same release; always run a two-name overlap window.
  • Keep extra="forbid" — but remember a removed field then becomes an error, so coordinate removal with operators.
  • Treat default-value changes as behavioural changes and announce them.
  • Version the schema in changelogs so every environment knows which names are valid.

The interaction between extra="forbid" and removal is the subtle guardrail. forbid is what makes your schema strict — it rejects unknown variables — but that same strictness means a removed field turns any environment that still sets the old variable into a hard startup failure. So removal has to be coordinated: stop setting the variable in every environment first, confirm nothing sets it, and only then delete the field. This is the mirror image of the rename discipline — additive changes (adding an alias) are safe to ship immediately, subtractive changes (removing a field under forbid) must wait until the environments have caught up. Treating default and constraint changes as behavioural is the third guardrail: a changed default silently alters what a process does for every environment that relied on the old value, so it deserves the same announcement and staged rollout as a code change, not a quiet commit buried in a larger diff where a reviewer will miss its blast radius.

Four schema-evolution guardrails Never rename and remove in one release, coordinate removal because forbid turns a leftover variable into an error, treat default changes as behavioural, and version the schema in changelogs. Two-name windowforbid + removal Defaults are behaviourVersion it never rename and remove in the same release a removed field makes a leftover variable an error announce default and constraint changes changelog which names are valid per version
Overlap renames, coordinate removals with forbid, announce default changes, and version the schema.

Version the schema, not just the code

A settings schema deserves the same versioning discipline as an API, because that is what it is — a contract about which variables exist, what they are named, and what values they accept. When you rename DB_DSN to DATABASE_URL or retire legacy_timeout, you are changing that contract, and the operators who set the variables need to know which version of the contract their environment must satisfy. Recording each schema change in a changelog — “v2.3: DB_DSN deprecated in favour of DATABASE_URL, removed in v3.0” — turns tribal knowledge into a written document, so an operator upgrading from an old version to a newer one can see exactly which variables to rename, and by which release each old name stops working, before they upgrade rather than after something breaks.

This versioning also gives the deprecation warnings teeth. A warning that says “legacy_timeout is deprecated; use REQUEST_TIMEOUT (removed in v3)” is only actionable if “v3” means something — a real, scheduled release with a changelog entry. Tie each deprecation to a named removal version, track them, and the schema’s evolution becomes predictable rather than a series of surprises. The alternative — deprecations with no deadline and removals with no announcement — is how a configuration surface becomes something everyone is afraid to touch, because no one is sure which variables are still load-bearing and which are safe to remove.

The practical artifact is small: a section in your changelog or a dedicated CONFIG_CHANGES.md that lists, per release, the variables added, renamed (with both names and the overlap window), deprecated (with the removal version), and removed. Combined with the CI test that constructs the model under both old and new shapes, it makes every schema change reviewable and every migration checkable, which is what lets configuration evolve at the pace of the product rather than lagging behind it. Teams that skip this end up in a familiar bad place: a configuration surface so poorly understood that renaming a variable feels risky, so nobody does it, and the schema calcifies with awkward names and dead fields because the cost of changing them safely was never paid down. Versioning and testing the schema is what keeps that cost low, so evolution stays a routine operation rather than a feared one.

A schema changelog tracks names across versions Across versions the schema changelog records that DB_DSN is deprecated in v2.3 with DATABASE_URL as its replacement and removed in v3.0, so operators know which names are valid per version. v2.2 DB_DSN only the old name v2.3 — overlap DB_DSN + DATABASE_URL DB_DSN deprecated v3.0 DATABASE_URL old name removed CONFIG_CHANGES.md — names valid per version operators upgrading know exactly which variables to rename, and by when
A per-version record of which names are valid turns migration into a checklist, not a guess.

Troubleshooting

  • Old pods crash after a variable rename — the alias window was skipped; re-add AliasChoices and redeploy.
  • extra not permitted after removing a field — an environment still sets the old variable; stop setting it before removing the field. See Handling Breaking Changes in Production Config Schemas.
  • Deprecation warning never appearswarnings filters suppress it; run CI with PYTHONWARNINGS=error::DeprecationWarning.
  • Both names set with different valuesAliasChoices takes the first match; document which name wins.

These symptoms are the failure modes of skipping or mis-sequencing a step. Old pods crashing after a rename means the alias window was skipped entirely — the variable was renamed in one move — so the fix is to re-add AliasChoices and redeploy, restoring the overlap. extra not permitted after a removal means an environment still sets the deleted variable, which is forbid doing its job; stop setting it before removing the field. A deprecation warning that never appears is usually Python’s warning filters suppressing DeprecationWarning by default — run CI with PYTHONWARNINGS=error::DeprecationWarning so a still-set deprecated field fails the build and you actually notice the deadline. And when both names are set with conflicting values, remember AliasChoices takes the first present name, so document which one wins and prefer setting only one during the migration.

Four schema-evolution symptoms and their fixes Old pods crashing means re-add the alias, extra not permitted means stop setting the old variable, a missing deprecation warning means enable it in CI, and conflicting names means document which wins. symptom fix old pods crash after rename re-add AliasChoices, redeploy extra not permitted after removal stop setting the old variable first deprecation warning silent enable DeprecationWarning in CI both names set, differ first match wins — document it
Each symptom is a skipped or mis-ordered step in the additive-then-subtractive sequence.

Frequently asked questions

How do I rename a config field without breaking running pods?

Accept both names during a migration window using AliasChoices in validation_alias, deploy, switch every environment to the new name, then remove the old alias in a later release. The two-name window means old and new pods both validate. The critical part is the sequencing: the alias must be live before any environment switches to the new name, and the old name must stay accepted until every pod running the old code has cycled out. Compress those into one step — rename the variable and remove the old alias together — and any pod still running when the variable flips starts raising ValidationError, which during a rolling deploy shows up as a partial outage for whatever fraction of traffic those crashing pods were serving. The overlap window is precisely the buffer that absorbs the gap between “the variable changed” and “every pod knows about it”.

How can I deprecate a configuration field safely?

Keep the field optional, emit a warning in a model_validator when it is supplied, and document the removal release. Remove it only after every environment has stopped setting it. The deprecation warning is the signal, but it only works if someone sees it — Python suppresses DeprecationWarning by default, so run CI (and ideally staging) with PYTHONWARNINGS=error::DeprecationWarning so a still-set deprecated field turns the warning into a build failure that forces action. Pair the warning with a named removal version in its message and a changelog entry, so “deprecated” comes with a deadline rather than lingering indefinitely. The removal itself is the subtractive step, gated on confirming no environment still supplies the variable, because under extra="forbid" a leftover variable becomes a hard startup error the moment the field is gone.

Is changing a default value a breaking change?

It can be. A changed default silently alters behaviour for any environment that relied on the old one. Treat default changes like code changes — announce them and roll them out deliberately. The trap is that a default change looks harmless in the diff — one literal edited — but its blast radius is every environment that did not set the variable, because those are exactly the environments running on the default. If a pool_size default goes from 10 to 50, every service that never set POOL_SIZE suddenly opens five times as many connections at the next deploy, with no configuration change on their side to hint at why. The safe path is to treat the change as behavioural: announce it, note it in the changelog, and if the impact is significant, ship it behind an explicit opt-in first so environments adopt the new value deliberately rather than by surprise.

Key takeaways

The invariant: no field is renamed or removed without an overlap window where both the old and new schema validate. Evolution is additive first, subtractive only after every environment has migrated. That single rule covers every case on this page — a rename opens with an alias and closes with its removal; a field is soft-removed as an optional, warned field before it is deleted; and a default or constraint change is announced and rolled out rather than slipped in. In each, the additive part ships immediately and safely because it cannot break either running version; the subtractive part waits until every instance and every environment has demonstrably moved past the old shape.

The reason this matters is that a settings schema is not private to one version of your code — during every rolling deploy it is shared by two. Any change that makes the old and new schemas disagree about a valid configuration is a change that can break whichever version is caught on the wrong side. Keep them in agreement throughout the transition, back the discipline with a CI test that constructs the model under both the old and new shapes, and configuration can evolve as freely as the rest of your code without ever forcing a downtime window to do it.