Deprecate Config Fields Without Breaking Deploys

Renaming REDIS_URL to CACHE_URL looks like a two-line change and is not, because code and configuration do not deploy together. The moment the new code lands, every environment still supplying the old name fails to boot; if you change the environments first, the old code fails instead. Staging the change so both names work for a while removes the coordination problem entirely, and costs one alias and a warning. This page covers the sequence, extending the schema evolution and versioning topic.

Problem 1: a rename that must be simultaneous

# ANTI-PATTERN: this deploy requires every environment to have changed already
class Settings(BaseSettings):
    cache_url: str          # was redis_url; the old variable no longer resolves

There is no ordering that makes this safe. Deploy the code first and any environment still supplying REDIS_URL fails at startup with a missing required field. Change the environments first and the running code stops finding its variable. The only way through is a synchronised change across every environment and the deploy, which is exactly the kind of coordination that fails when one environment is owned by another team or deploys on a different cadence.

Accepting both names for a period removes the ordering constraint. Environments can be updated whenever it suits them, the code deploys independently, and the removal happens later when nothing is using the old name — which you can verify rather than assume.

A hard rename requires simultaneity; an overlap does not With a hard rename either the code or the environments must change first and one of them breaks, while an overlap period lets both names work so the two changes are independent. hard rename code first: old environments fail to boot environments first: running code breaks only a synchronised change works overlap period both names accepted, either order environments move when convenient removal happens when usage is zero
The overlap converts a coordination problem into two independent changes.

Problem 2: a warning nobody sees

# ANTI-PATTERN: emitted per use, at DEBUG, into a log nobody greps
if "REDIS_URL" in os.environ:
    logger.debug("REDIS_URL is deprecated")

A deprecation warning has one job: to reach the person who can act on it, before the removal. A DEBUG line in a production log fails at that, and so does a warning emitted so often that it becomes noise. The predictable result is a removal that surprises somebody, which is the outcome the whole exercise was meant to avoid.

Emit once, at startup, at WARNING, naming the old field, the new field and the removal date. Then make it countable: increment a metric or emit a structured field with the environment name, so “which environments still use this?” is a query rather than a survey. The metric is what turns removal from a judgement call into a check.

Problem 3: reusing a name whose meaning changed

# ANTI-PATTERN: same variable, different units, silent everywhere
timeout: int      # was seconds; this release interprets it as milliseconds

Every environment continues to supply a valid value, every boot succeeds, and every timeout is now a thousand times shorter than intended. No validation catches it because the value is still an integer in a plausible range, and the symptom — intermittent failures under load — points nowhere near configuration.

A change of meaning is a new field. Name it timeout_ms, make it required or defaulted appropriately, and deprecate the old one through the normal sequence. The cost is one extra name; the benefit is that a misconfiguration becomes impossible rather than silent, and the change is visible in every environment’s diff.

Secure implementation

# config.py — accept both names, warn once, with the removal date in the message
import logging
import os
from typing import Any
from pydantic import AliasChoices, Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

logger = logging.getLogger(__name__)

# 1. one table: old name, new name, and when the old one stops working
DEPRECATED: dict[str, tuple[str, str]] = {
    "REDIS_URL": ("CACHE_URL", "2026-11-01"),
}


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

    # 2. both names resolve during the overlap; the field itself is the new name
    cache_url: str = Field(validation_alias=AliasChoices("CACHE_URL", "REDIS_URL"))

    environment: str = "local"

    @model_validator(mode="after")
    def _warn_on_deprecated_names(self) -> "Settings":
        for old, (new, removal) in DEPRECATED.items():
            # 3. only warn when the old name is what actually supplied the value
            if old in os.environ and new not in os.environ:
                logger.warning(
                    "config %s is deprecated, use %s (removed after %s) [environment=%s]",
                    old, new, removal, self.environment,     # 4. structured: countable per environment
                )
        return self
# after the removal date, once usage is zero
class Settings(BaseSettings):
    model_config = SettingsConfigDict(extra="forbid")
    cache_url: str            # 5. alias removed; extra="forbid" now rejects REDIS_URL loudly

AliasChoices is what makes the overlap free of branching. The field is defined once under its new name, both variables resolve to it, and precedence is explicit — the first alias listed wins, so an environment that sets both gets the new name’s value. Without it you would need two optional fields and a resolution step, which is more code and one more thing to get wrong.

Step 5 is the part people forget to plan. With extra="forbid", removing the alias means a leftover REDIS_URL becomes an unknown variable and fails the boot — which is the correct behaviour, because a variable nobody reads is a lie about how the service is configured. Make that the deliberate final step rather than a surprise: the failure is exactly what tells the last stragglers to clean up.

Three stages of retiring a configuration field First both names are accepted with a warning, then usage is verified as zero through a metric, then the alias is removed so the old name fails as an unknown variable. 1. overlap both names, one warning 2. verify metric shows zero usage 3. remove old name now fails loudly the second stage is the one that turns removal into a check rather than a guess
Three stages, and the middle one is the reason the third is uneventful.

Removing a field entirely

Deleting a setting that nothing replaces follows the same shape with one difference: there is no new name to migrate to, so the overlap exists purely to let environments clean up. Keep the field defined and unused for one release, warning when it is present, then remove it and let extra="forbid" reject the leftover.

The temptation is to skip the overlap because “nothing reads it anyway”, and that reasoning is right about the code and wrong about the deploy. With extra="forbid", removing a field while an environment still sets the variable turns the next deploy of that environment into a failed boot — for a variable that had no effect. That is a genuinely annoying way to break a deploy, and the one-release overlap avoids it for the cost of leaving a line in the model.

The same logic applies to a field that becomes optional after being required, in reverse: relaxing a constraint is always safe to deploy immediately, while tightening one needs the overlap treatment. A useful rule of thumb is that any change which could make a currently-valid environment invalid — adding a required field, forbidding an old name, narrowing an enum — is a two-stage change, and any change that widens what is accepted is a one-stage change.

One practical addition makes the whole sequence easier to run: keep the deprecation table in the codebase rather than in a ticket. A dictionary mapping old names to their replacement and removal date is readable, greppable, and can be printed by the same command that reports configuration sources — so “what is being retired and when?” is answerable from a running container. It also gives the removal change an obvious shape: delete the row, delete the alias, and the diff shows both halves together.

Gotchas and version-specific behaviour

  • AliasChoices accepts several names for one field, with the first listed taking precedence.
  • With extra="forbid", removing an alias makes the old variable an unknown-key error — plan that as the final stage.
  • Warn only when the old name actually supplied the value; warning whenever it merely exists produces noise in environments that set both during migration.
  • A change of meaning is a new field, not a reinterpreted one; reusing the name produces a silent, valid, wrong configuration.
  • Put the removal date in the warning text so it survives being pasted into a ticket without context.
  • Widening changes — a new optional field, a relaxed constraint — deploy in one stage; narrowing changes need two.
  • Any generated schema or documentation should mark the field deprecated too, or the editor keeps suggesting the old name.

Production parity checklist

  • Every rename ships with both names accepted for at least one full release cycle.
  • The deprecation warning fires once at startup, at WARNING, naming old, new and the removal date.
  • Usage of the deprecated name is countable per environment, and that count is checked before the alias is removed.
  • Removal is a separate, deliberate change, not part of the deploy that introduced the new name.
  • A changed meaning always gets a new field name rather than a reinterpretation of the existing one.
  • Generated schemas and documentation mark deprecated fields so editors stop suggesting the old name to whoever edits a manifest next.
  • The deprecation table lives in the codebase, so what is being retired and when is answerable from a running container.
Which changes need one stage and which need two Widening changes such as adding an optional field or relaxing a constraint deploy in one stage, while narrowing changes such as adding a required field or removing an alias need an overlap period. one stage: widening a new optional field a relaxed constraint an additional accepted alias no valid environment becomes invalid two stages: narrowing a new required field removing an alias or a field a narrower enum or range an existing environment could fail
One question decides the process: could this make a currently-valid environment invalid?

Frequently asked questions

Why can I not just rename a configuration field?

Because the code and the environments deploy at different moments. A rename that lands before every environment is updated fails the boot of whichever environment still has the old variable, and updating the environments first breaks the code currently running. Rolling back does not necessarily help either, since an environment that has already been changed now fails against the previous release. Accepting both names for a period removes the ordering constraint entirely, which is worth far more than the one line it costs.

How long should the overlap period be?

Long enough that every environment has been deployed at least twice, including the ones nobody touches often. A release cycle is a reasonable unit; measuring in days tends to miss the environment that deploys monthly, or the one owned by a team that was on holiday. Because the cost of a longer overlap is a single alias and a warning line, err generously — the expensive mistake is removing too early, not leaving an alias in place for an extra month.

Should a deprecated variable produce a warning or an error?

A warning during the overlap and an error afterwards. A warning that nobody reads is the failure mode, so emit it once at startup at WARNING level with the old name, the new name and the removal date, and count it as a metric so you can see which environments are still affected. After removal, extra="forbid" turns the leftover variable into a startup error, which is correct: a variable nobody reads is a false statement about how the service is configured.

How do I find out who is still using the old name?

Emit a metric or a structured log field naming the environment when the deprecated name supplies the value, and check it before removing. Searching deployment manifests catches the environments you know about, which are not the ones that cause problems — the problematic case is always a manifest in a repository you had forgotten, or a value set by hand on a host somebody built two years ago. A counter that reads zero across a full release cycle is evidence; a search is a hope.

What about a field whose meaning changed rather than its name?

Treat it as a new field with a new name. Silently reinterpreting an existing variable is the hardest kind of change to debug, because every environment looks correct, every boot succeeds and every behaviour is wrong — a timeout that changed from seconds to milliseconds passes validation everywhere and produces intermittent failures that point nowhere near configuration. A new name makes the change visible in every environment’s diff and makes the old value impossible to misapply.

Key takeaways

Configuration changes are deploy-ordering problems before they are code problems. A rename that requires the code and every environment to change at the same moment will eventually be attempted while one environment is out of reach, and the failure lands on whoever deploys next. Accepting both names through AliasChoices for a full release cycle makes the two changes independent, which is the whole trick.

The other two pieces are the warning and the verification. Emit once at startup with the old name, the new name and a removal date, and count usage per environment so that removing the alias is a check rather than a judgement. Then remove deliberately, knowing extra="forbid" will reject the leftover variable — which is the behaviour you want, and the reason it should not arrive as a surprise. Apply the same two-stage treatment to anything that narrows what is accepted, and deploy widening changes in one step. The broader versioning practices are on the schema evolution topic page.