Handling breaking changes in production config schemas

A rolling deployment runs the old and new schema against the same environment at the same time. Rename a variable, make an optional field required, or remove a key, and one side breaks the instant the change lands — unless you stage it. This page sequences breaking schema changes safely, extending Schema Evolution & Versioning.

Problem 1: rename in a single release

# ANTI-PATTERN: old pods set DB_DSN, new model only accepts DATABASE_URL
class Settings(BaseSettings):
    database_url: str    # old pods inject DB_DSN -> ValidationError during rollout

The moment you switch the injected variable, every pod still running the old image fails to boot.

Problem 2: optional made required immediately

# ANTI-PATTERN: a field that was optional is now mandatory
class Settings(BaseSettings):
    region: str          # environments that never set REGION now crash

Promoting a field to required without a grace period breaks every environment that did not already set it.

Secure implementation

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

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

    # Step 1: accept both names during the overlap window.
    database_url: str = Field(validation_alias=AliasChoices("DATABASE_URL", "DB_DSN"))

    # Step 2: introduce the new field as optional with a safe default first.
    region: str = "us-east-1"

    @model_validator(mode="after")
    def warn_legacy(self) -> "Settings":
        # Emit a metric/log when the legacy name is still in use so you know
        # when it is safe to drop the alias.
        return self

The change ships in stages: accept both names, roll out, switch every environment to the new name, confirm the legacy name is unused, then remove the alias in a later release. New required fields land as optional-with-default first and are promoted only once every environment sets them.

Gotchas & version-specific behaviour

  • AliasChoices returns the first matching source — document which name wins if both are set.
  • extra="forbid" means a removed field becomes an error; stop injecting it before deleting it.
  • Use populate_by_name=True so both the field name and its alias resolve.
  • Run CI with PYTHONWARNINGS=error::DeprecationWarning so deprecations cannot be ignored.

Production parity checklist

  • Every rename ships with a two-name alias overlap window.
  • New required fields are optional-with-default first, promoted later.
  • Removal happens only after telemetry shows the old name/field is unused.
  • CI validates the model with both old and new variable names during the window.
  • Schema changes are recorded in a changelog operators read before deploying.

Conclusion

Stage every breaking change — alias the rename, default the new field, remove last — and old and new pods coexist without a failed boot. For the alias mechanism itself, see Backward-Compatible Config with validation_alias.