Backward-compatible config with validation_alias

Renaming a configuration variable is a breaking change — unless the model accepts both the old and new name during the transition. validation_alias with AliasChoices is the pydantic v2 mechanism for exactly that. This page applies it, extending Schema Evolution & Versioning.

This page is the mechanics of the alias itself — the exact pydantic v2 API that lets one field answer to more than one variable name. Where the schema-evolution overview covers the process of a rename across a rolling deploy, this page zooms in on the tool: what validation_alias does, how AliasChoices orders its candidate names, why populate_by_name matters alongside it, and how the v1 Field(env=...) idiom maps onto the v2 way. Get the tool right and the two-name window that the whole process depends on becomes a single field declaration.

The key idea is that a field’s validation alias decouples the Python attribute name from the input name pydantic looks for. Normally database_url reads a DATABASE_URL variable; a validation alias overrides that default, and AliasChoices supplies an ordered list of acceptable names so the field accepts any of them as input. That list is exactly what makes a rename backward-compatible — during the migration, the list contains both names.

It is worth being precise about what “backward-compatible” buys you, because the phrase is doing real work. A backward-compatible change is one that a system built against the old contract can still satisfy. For a settings model, the old contract is “provide DB_DSN”, and the alias keeps that contract honoured — a pod or an environment still injecting DB_DSN continues to validate — while simultaneously offering the new contract, “provide DATABASE_URL”. Both contracts are live at once. That dual-contract window is the whole point: it lets the two sides of the rename (the code that reads the config and the environment that provides it) move independently, at their own pace, rather than requiring a synchronized switch that a rolling deploy cannot deliver.

The alias is also invisible to the rest of your code, which is a feature. Downstream code reads settings.database_url throughout — it never mentions either variable name — so the migration touches only the field’s alias declaration and the environments’ injected variables, not the tens or hundreds of call sites that read the value through the settings object. This is the payoff of routing configuration through a single model: a rename is a change at the boundary, and the interior of the application is oblivious to it. Compare the alternative of os.getenv("DB_DSN") scattered across modules, where a rename means finding and updating every read — the model localises the change to one line and leaves every consumer of the value untouched.

Problem 1: a hard rename

# ANTI-PATTERN: old name stops working the instant you rename the field
class Settings(BaseSettings):
    database_url: str        # was DB_DSN; old deploys inject DB_DSN -> error

Every process still injecting DB_DSN fails to construct the model. Rename the field to database_url and the model now looks for DATABASE_URL and nothing else, so any deploy — or any lingering pod — that still provides DB_DSN gets a missing error for database_url and refuses to start. During a rolling deploy that is a partial outage for the traffic those crashing pods were serving; even between deploys it is a trap for any environment whose variable you have not yet updated to the new name. The rename was a one-line change to the field name, but its effect is to instantly invalidate every environment still using the old variable, with no grace period.

A hard rename rejects the old variable immediately After renaming the field, the model accepts only DATABASE_URL, so a deploy still injecting DB_DSN gets a missing error and fails to construct. old deploy injects DB_DSN model wants DATABASE_URL only missing → crash no grace period
A hard rename makes the model accept only the new name, so the old variable becomes a missing-field error at once.

Problem 2: the v1 env= idiom that moved

# ANTI-PATTERN: v1 style, removed in v2
database_url: str = Field(env="DB_DSN")   # 'env' is not how v2 aliases work

Field(env=...) is a v1 idiom; v2 uses validation_alias. In pydantic-settings v1, Field(env="DB_DSN") was how you pointed a field at a specific environment variable, and it accepted a single name or a list. In v2 that argument is gone — there is no env= on Field — and the mechanism moved to validation_alias, which takes a single alias string or an AliasChoices(...) for multiple names. Code copied from a v1 tutorial that uses env= will not raise a helpful “use validation_alias” message; it will typically just ignore the argument and read the field’s default name, so the field silently reads the wrong variable. When migrating, replace every Field(env=...) with Field(validation_alias=...), using AliasChoices wherever the old form listed more than one name. Because the silent-ignore behaviour makes this bug invisible at import, a good safeguard is a quick grep for env= across the codebase before you trust a v2 migration is complete — any remaining Field(env=...) is a field reading the wrong variable, and the grep turns an easy-to-miss mistake into a finite list to fix.

The v1 env argument moved to validation_alias in v2 In pydantic v1 Field(env="DB_DSN") pointed a field at a variable; in v2 that argument is gone and the mechanism is Field(validation_alias=AliasChoices(...)). pydantic v1 pydantic v2 Field(env="DB_DSN") Field(validation_alias= AliasChoices(...)) no env= in v2 — a copied v1 idiom reads the wrong variable silently
The v1 env= argument does not exist in v2; use validation_alias, with AliasChoices for multiple names.

Secure implementation

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

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

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

settings = Settings()
# Both `DATABASE_URL=...` and `DB_DSN=...` populate database_url during migration.

AliasChoices("DATABASE_URL", "DB_DSN") accepts either variable, so old and new deployments both validate. After every environment switches to DATABASE_URL, drop DB_DSN from the alias in a later release.

The order of names in AliasChoices is significant: pydantic checks them left to right and uses the first one that is present. Listing the new name first means that once an environment has migrated and sets DATABASE_URL, that value wins even if a stale DB_DSN is somehow also present — the migration resolves toward the new name, which is what you want. populate_by_name=True is the companion setting: with a validation_alias in place, the field can normally only be populated by its aliases, not its Python name, which breaks constructing the model directly in tests (Settings(database_url=...)). Turning populate_by_name on restores the ability to set the field by its attribute name, so your tests and any programmatic construction keep working alongside the alias.

The forgotten-populate_by_name bug is one people hit the first time they add an alias. You add validation_alias=AliasChoices(...) to a field, and suddenly a test that did Settings(database_url="postgresql://...") fails, because with the alias in place the field is no longer populated by its attribute name — only by its aliases. The error is confusing because the code that broke did not change. Setting populate_by_name=True in model_config fixes it by allowing both the attribute name and the aliases as input, which is almost always what you want on a settings model that has any aliased fields. Treat it as a companion to AliasChoices: wherever you add one, check that the other is enabled, and add a test that constructs the model by attribute name so a missing populate_by_name is caught immediately rather than the next time someone writes such a test.

Do not overuse aliases, though. An alias is migration scaffolding, not a permanent convenience — a field that permanently answers to three different names is harder to reason about, not easier, and it invites the “both set with different values” confusion. The healthy pattern is that a field has exactly one name in steady state, briefly grows a second during a rename, and returns to one when the migration completes. If you find a field carrying aliases with no migration in progress, that is a sign a cleanup was skipped, not a deliberate feature to preserve.

AliasChoices resolves a field from the first present name AliasChoices lists DATABASE_URL then DB_DSN; pydantic uses the first present name to populate database_url, and populate_by_name also allows the Python attribute name for tests. DATABASE_URL (new) DB_DSN (legacy) database_url (attr) AliasChoices first present wins database_url populated populate_by_name lets the attr name work in tests
AliasChoices takes the first present name; populate_by_name keeps the attribute name usable in code and tests.

Gotchas & version-specific behaviour

  • List the new name first in AliasChoices — it is preferred when both are set.
  • populate_by_name=True lets the field be set by its Python name as well as the aliases.
  • extra="forbid" means once you remove the old alias, the old variable becomes an error — stop injecting it first.
  • This replaces v1’s Field(env=...); there is no env= argument in v2.

A distinction worth knowing is between validation_alias and serialization_alias. The validation alias controls the input name — what pydantic reads a value from — which is what a rename cares about. The serialization alias controls the output name when you dump the model, which is a separate concern; a rename usually touches only the validation side, and conflating the two is a common source of confusion. For nested inputs there is also AliasPath, which lets a field read from a path inside a structured source rather than a flat name, but for the flat environment-variable case that settings models usually work with, AliasChoices of plain names is what you want. Keep the mental model simple: validation_alias=AliasChoices("new", "old") is the whole tool for a backward-compatible rename.

The case-sensitivity of aliases interacts with the model’s case_sensitive setting, which is worth checking when a rename does not seem to take effect. If your model uses the common case_sensitive=False, the alias names match environment variables case-insensitively, so DATABASE_URL and database_url both resolve — which is usually what you want given environment-variable convention. If you have set case_sensitive=True, the alias must match the exact casing the environment provides, and a mismatch there produces a missing error for a variable you are certain you set. When an alias appears not to work, verifying the case-sensitivity setting is a quick thing to rule out before suspecting anything subtler.

One more practical note: an alias is a fine place to accept more than the two names of a single rename when history warrants it. If a variable has been renamed twice over its life — DSN to DB_DSN to DATABASE_URLAliasChoices can list all three during a transition, accepting every historical name so no environment is stranded. In steady state you would narrow it back to one, but the list can be as long as the migration needs, which makes even a multi-hop rename a simple matter of listing the names in preference order rather than a painful sequence of separate breaking changes.

Four validation_alias gotchas List the new name first, turn on populate_by_name for tests, remember forbid makes a removed alias's variable an error, and use validation_alias not the v1 env argument. New name firstpopulate_by_name forbid + removalnot env= preferred when both variables are set lets the attribute name work in tests stop injecting the old variable before removal v2 uses validation_alias, not Field(env=...)
Order names new-first, enable populate_by_name, coordinate removal with forbid, and use validation_alias.

Production parity checklist

  • The rename ships with both names accepted via AliasChoices.
  • The new name is listed first.
  • Telemetry confirms the old name is unused before the alias is removed.
  • CI validates the model with both the old and new variable set.
  • Removal of the old name is a separate, later release.

The telemetry item is what tells you the migration is actually finished. It is tempting to remove the old alias once you think every environment has switched, but “think” is not “know”, and under extra="forbid" a single missed environment turns the removal into a startup failure. Confirm it instead: log or count which alias name each process actually resolved (a one-line metric incremented in a model_validator), and only remove DB_DSN from the alias once that signal shows a sustained zero of old-name usage across the whole fleet for long enough that no lagging instance or forgotten environment remains. Pair that with a CI test that constructs the model with the old name set and again with the new name set — proving the overlap works — and the rename becomes a fully verified, reversible operation rather than a hopeful one.

validation_alias production-parity checklist Ship the rename with both names via AliasChoices, list the new name first, confirm the old name is unused via telemetry, validate both names in CI, and remove the old name in a separate later release. The rename ships with both names accepted via AliasChoices The new name is listed first in AliasChoices Telemetry confirms the old name is unused before removing the alias CI validates the model with both the old and new variable set Removal of the old name is a separate, later release
Five checks that make an aliased rename verified and reversible rather than hopeful.

Key takeaways

validation_alias with AliasChoices turns a breaking rename into a no-downtime, two-name window. The tool is small and precise: a validation alias decouples the field’s Python name from the input name, and AliasChoices supplies an ordered list of acceptable names so the field answers to any of them during a migration. List the new name first so migrated environments resolve toward it, turn on populate_by_name so tests and programmatic construction keep working, and remember that validation_alias — not the removed v1 Field(env=...) — is the v2 way.

The tool is only half the job; the sequencing is the other half. Ship the alias with both names, confirm via telemetry that the old name has fallen out of use, and remove it in a separate later release, because under extra="forbid" a premature removal turns any lingering old-name environment into a startup failure. Back it with a CI test that validates both names, and a rename that would otherwise be a coordinated, downtime-inducing flag day becomes a routine, reversible, one-field change you can make whenever a variable’s name no longer fits. For sequencing renames, promotions, and removals together, see Handling Breaking Changes in Production Config Schemas.