Configuration Precedence Rules

When DATABASE_URL is set in the shell, in a .env file, and in config.yaml, exactly one of them must win — and it must be the same one on every developer’s laptop and in production. Undefined precedence is the root cause of the “works on my machine” class of bug. This page makes the order explicit and deterministic.

The failure mode is worth picturing. A developer sets DATABASE_URL in their .env and everything works locally; production sets it as an injected environment variable and everything works there — until someone’s code loads the .env after reading the environment, or with override=True, and now the two environments resolve the same key from different sources. The value that wins on the laptop is not the value that wins in production, and the divergence is invisible until it causes an incident. The cure is not vigilance but structure: define the precedence once, so there is a single answer to “which source wins” that holds everywhere.

Precedence is the rule that ties the configuration sources together: it decides whether an environment variable or a .env file value reaches the application.

The property that matters most is determinism: for any key, the same source must win in every environment, and the order must be defined in exactly one place rather than emerging implicitly from the order your modules happen to import. When precedence is implicit, a value can resolve differently on a laptop than in production — the classic “works on my machine” bug — because the two environments happened to consult the sources in a different order. Making the order explicit and encoding it once removes that ambiguity: the resolution becomes a property of your code, provably identical everywhere it runs, rather than an accident of runtime.

The conventional order, highest priority first, is CLI flags, then OS environment variables, then the .env file, then a checked-in config file, then hard-coded defaults. This ordering is not arbitrary — it tracks how deliberately a value was set, which the rest of this page unpacks — and the single rule that governs it is simple: the first source that supplies a value wins, and everything below it is a fallback.

Why this exact order

The ordering is worth justifying source by source, because understanding why each layer sits where it does is what lets you place a new source correctly.

Configuration precedence ladder From highest to lowest priority: CLI flags, OS environment variables, the .env file, a config file, then hard-coded defaults. The first source with a value wins. CLI flags — highest priority OS environment variables .env file config file (YAML / JSON / TOML) hard-coded defaults — lowest priority wins fallback
The first source that supplies a value wins; everything below it is a fallback.

CLI flags rank highest because a flag typed on the command line is the most deliberate, most immediate signal a human can send — “for this one run, use exactly this” — and an operator reaching for a flag under pressure expects it to win over everything. OS environment variables rank next because they are the deployment’s considered configuration, set by the orchestrator or the shell for this specific process; they are more authoritative than a file committed months ago but yield to an explicit one-off flag.

The .env file sits below the environment deliberately: it is a local developer convenience, and letting it outrank the real environment would mean a stale file on a laptop — or worse, one that slipped into a container — could override an injected production value. A checked-in config file ranks below .env as the team’s shared baseline: it is committed and reviewed, so it holds the defaults everyone agrees on, but any individual environment must be able to override it. And hard-coded defaults sit at the very bottom as the last-resort values that keep the process runnable when nothing else is set. Read top to bottom, the ladder goes from “this exact run” to “everyone’s fallback”, and every source’s position follows from how specific and how deliberate its values are.

There is one placement mistake this framing prevents that is worth calling out: putting a .env file above the environment (via override=True). It feels harmless during local development, where the .env is the source of truth, but it inverts the intended relationship — now a committed or stale file outranks the real, injected environment. In production, where the orchestrator provides the authoritative values, an override=True .env that somehow reaches the container silently replaces them, and the symptom is maddening: the variable is set correctly in the environment, yet the app uses the file’s value. Keeping .env strictly below the environment (override=False) is not a stylistic preference; it is what makes the file a local convenience rather than a production hazard.

Secure implementation

# config/precedence.py
from typing import Any, Mapping

# Sources are ordered highest-priority first.
def resolve(key: str, *sources: Mapping[str, Any]) -> Any:
    for source in sources:
        value = source.get(key)
        if value is not None:
            return value            # first non-None hit wins
    raise KeyError(f"No source provided a value for {key!r}")

# cli > env > dotenv > file > defaults
DATABASE_URL = resolve(
    "DATABASE_URL", cli_args, os.environ, dotenv_dict, file_config, defaults
)

The order is encoded in one place, as the argument order to resolve. There is no environment-specific branching, so the precedence is provably identical everywhere it runs.

The whole design collapses to one idea: the source order is data, expressed as the argument order to resolve, not logic scattered across the codebase. Because the order lives in a single call, there is exactly one place to read to know how any key resolves, and exactly one place to change if the policy ever needs to. The resolve function itself is trivial — walk the sources in order, return the first non-None value — which is the point: the simplicity is what makes the behaviour predictable. Critically, there is no if ENV == "prod" anywhere; the same argument order runs in development, CI, and production, so the precedence cannot drift between them. A missing key raises rather than silently returning None, so a required value that no source provides fails loudly at resolution time.

The is not None check is a deliberate choice worth noting. It means an explicitly-set empty string from a higher-priority source still wins — an operator who sets PROXY_URL= to blank it out overrides a lower-priority default — because the empty string is not None, only an absent key is. If your semantics require treating an empty value as “unset”, you would check for emptiness too; the point is that the resolver’s notion of “a source supplied a value” is a single, explicit rule you can read and adjust in one place, rather than an implicit behaviour scattered across the codebase. This is the recurring benefit of centralising: every subtlety of the resolution — what counts as present, what order wins, what happens when nothing supplies a value — lives in one function you can reason about.

One resolver walks the ordered sources and returns the first hit The resolve function checks CLI args, then environment, then dotenv, then file, then defaults in order, returning the first non-None value or raising if none supply the key. resolve(key, cli, env, dotenv, file, defaults) cli args os.environ .env dict file config defaults first non-None value wins → value returned none → KeyError same argument order in every environment — no branching
One resolver walks the sources in a fixed order and returns the first hit; the order is data, not scattered logic.

Configuration reference

Source Priority Set where Notes
CLI flags 1 (highest) argparse / CLI Explicit operator override
OS environment 2 shell / orchestrator Wins over .env for parity
.env file 3 local file override=False keeps it below env
Config file 4 repo / mounted Structured, non-secret values
Defaults 5 (lowest) settings model Safe fallbacks only

The order tracks specificity of intent — the more deliberately someone set a value, the more it is respected. A CLI flag is the most deliberate signal a human can send (“for this run, use exactly this”), so it overrides everything. An OS environment variable is the deployment’s considered choice, more authoritative than a file committed months ago but less than a one-off flag. The .env file captures a developer’s local convenience; a config file captures the team’s shared baseline; and a hard-coded default is the last-resort value that keeps the process runnable when nothing else is set. Reading the table this way makes the ordering feel inevitable rather than arbitrary: the more effort someone went to to set a value, the higher it ranks, which is exactly the behaviour an operator expects under pressure. This “specificity of intent” framing is also what tells you where to slot a new source. Suppose you add a Parameter Store or a Vault source: does it represent a deployment-level decision (place it near the environment layer) or a shared baseline (place it near the config file)? The answer follows from how deliberately and how specifically that source’s values are set, not from any technical property of the source. Getting the placement right is the difference between a new source that behaves intuitively and one that surprises people by winning or losing unexpectedly.

Precedence tracks how deliberately a value was set CLI flags are the most deliberate signal and rank highest, followed by injected environment variables, the .env file, the shared config file, and hard-coded defaults as the last resort. more deliberate → higher priority CLI flag"for this run, exactly this" env variablethe deployment's choice .env filelocal developer convenience config filethe team's shared baseline defaultlast-resort fallback
The order tracks intent: the more deliberately a value was set, the more it outranks the layers beneath it.

The pydantic-settings source order

Most Python services do not hand-roll a resolve function; they use a settings model, and pydantic-settings has its own built-in precedence that you should understand because it is the same idea with fixed names. From highest to lowest, pydantic-settings resolves: init arguments passed to the constructor, then OS environment variables, then the .env file, then file secrets, then field defaults. The first source that supplies a value wins, exactly as in the hand-rolled resolver — the only difference is that the sources have specific names and the order is built in rather than expressed as arguments.

This is why a common question — “why does pydantic-settings ignore my .env value?” — has the answer “because a real environment variable of the same name outranks it.” That is not a bug; it is the same environment-above-file precedence that keeps injected production secrets winning over a committed file, and it gives you production parity for free. If you need to customise the order — to insert a Parameter Store source, or to reprioritise — pydantic-settings exposes a settings_customise_sources hook, but the default order is correct for the overwhelming majority of services and you should have a specific reason before overriding it. Whether you use the hand-rolled resolver or the model, the principle is identical: one declared order, first source wins, no per-environment branching.

The init-arguments layer deserves a note of its own, because it is what makes settings models pleasant to test. Passing a value directly to the constructor puts it at the very top of the order, above anything the ambient environment happens to contain, so a test can pin exactly the fields it cares about and let every other field resolve normally. That is far more robust than mutating os.environ inside a test and hoping the cleanup runs: the override is scoped to the object, not to the process, and it cannot leak into a later test. Treat init arguments as the model’s equivalent of a CLI flag — the most explicit signal available — and the mapping between the hand-rolled resolver and the model becomes one-to-one, which means the mental model you build reading one transfers directly to the other.

The pydantic-settings built-in source order pydantic-settings resolves init arguments, then environment variables, then the .env file, then file secrets, then field defaults, with the first source that supplies a value winning. pydantic-settings source order (highest first) 1 · init argumentstests, explicit overrides 2 · environment variablesthe deployed value 3 · .env filelocal convenience 4 · file secretsmounted secret files 5 · field defaultslast resort
pydantic-settings has the same precedence built in — environment above .env is why an injected value wins.

Deployment parity: local to production

  1. Local dev — defaults plus a .env supply most values; the shell can override ad hoc.
  2. CI — pipeline variables sit at the environment level; no .env present.
  3. Staging/Production — the orchestrator sets environment variables that outrank everything below; defaults catch only non-critical values.

The same fixed order behaves correctly in each environment precisely because it does not change. Locally, the lower layers — a .env file and defaults — do most of the work, and a developer can drop a CLI flag or export a shell variable to override any single value for one run. In CI, the pipeline sets variables at the environment level and there is no .env, so the environment layer supplies the values. In production, the orchestrator injects environment variables that sit above everything except an explicit flag, so the deployed values win and defaults only fill genuinely optional gaps. Nothing about the resolution logic differs across the three — only which layers happen to be populated — which is exactly why the same code gives the right answer everywhere.

The override affordance at the top of the ladder is what makes local development pleasant without compromising the model. Because CLI flags and shell-exported environment variables sit above the .env and defaults, a developer can override any single value for one run — DATABASE_URL=... python -m app, or a --port 9000 flag — without editing a committed file or disturbing anyone else. This is a feature of the ordering, not a special case: the highest layers exist precisely to let a human take deliberate control when they need to, and because those same layers are where production injects its values, the mechanism a developer uses for a one-off override is the same mechanism the orchestrator uses to configure production. One ordering serves both.

The parity guarantee is what lets you trust local testing. If a value resolves correctly against your local layers, and the only thing that changes in production is which layers are populated (injected variables instead of a .env), then a configuration that works locally will resolve the same way in production — assuming the required variables are actually set there, which the CI check enforces. This is the concrete payoff of determinism: local behaviour predicts production behaviour, because the resolution logic is identical and only the inputs differ.

The same order, different populated layers per environment Local dev populates .env and defaults, CI populates the environment layer, and production populates injected environment variables; the resolution order is identical in all three. Local devCIStaging / Prod .env + defaultspipeline env vars same resolve order only populated layers differ
The resolution order is byte-for-byte identical everywhere; only which layers carry values changes per environment.

Security boundaries & guardrails

  • Define the order exactly once; never re-implement it per module or per environment.
  • Keep .env strictly below OS environment (override=False) so injected secrets win.
  • Treat secrets as environment/secret-store sourced only — never as a checked-in config-file default.
  • Log the resolved source of each key (not its value) at startup for auditability.

The most important guardrail is “define the order exactly once”, because every re-implementation is a chance to get it subtly different, and two slightly different orders in two modules is precisely the drift this whole discipline exists to prevent. The .env-below-environment rule is the security-critical one: it guarantees a committed or stale local file can never clobber a secret the platform injected, so override=False (or an explicit “only set if not already present” guard) is non-negotiable. And logging the source — not the value — of each key at startup gives you an audit trail: when a value looks wrong, the log tells you which layer supplied it, turning “why is this value here?” from a hunt into a lookup. Never let a secret ride in a checked-in config-file default, because a config file is committed and readable; secrets belong in the environment or a secret store, which sit above the file in precedence anyway.

The source log deserves more than a one-line mention because it is the single most useful diagnostic for precedence problems. At startup, for each resolved key, log the key name and the name of the source that supplied it — never the value, which might be a secret. A line like DATABASE_URL ← environment or LOG_LEVEL ← .env turns the otherwise-invisible resolution into an auditable record. When a value is wrong, you no longer have to reason about which layer should have won; you read the log and see which one did. And when staging and production behave differently, diffing their startup source logs shows immediately that, say, TIMEOUT came from the environment in one and fell through to its default in the other. This log costs a few lines to produce and repays itself the first time a precedence question arises under time pressure.

The required-key CI check is the other habit that makes the discipline enforceable. It is easy for a required value to be present in every environment you tested and missing in one you did not, and because the resolver falls through to a default, the process starts anyway on the wrong value. A CI step that constructs the configuration against each environment’s real variable set and asserts that every required key resolves from a source above the defaults layer catches that gap before deploy: a key that should be injected but is not resolves to its default, the check sees it came from the defaults layer, and the build fails. This turns “a default silently leaked to production” from a latent incident into a red check on the pull request.

Four precedence guardrails Define the order once, keep .env strictly below the environment, keep secrets out of config-file defaults, and log the resolved source of each key. Define once.env below env Secrets not in filesLog the source one resolver, never re-implemented per module override=False so injected secrets always win never a config-file default; use env or a store log which layer supplied each key, not its value
One order, .env below the environment, no secrets in file defaults, and a source log for every key.

Troubleshooting

  • A value differs between laptop and prod — a source is set in one environment but not the other; log which source supplied the key.
  • .env value ignored — the key exists in os.environ, which outranks .env. Intended behaviour.
  • CLI flag has no effect — it was added to the wrong (lower) position in the source order.
  • Default leaks to production — a required key was unset in the target environment; add a CI check that every required key resolves above the defaults layer. See CLI vs Env vs File Precedence.

Every one of these symptoms is answered by the source log. “A value differs between laptop and production” is a source set in one environment but not the other — the log shows which layer supplied the key in each, and the diff is immediate. “.env value ignored” is the environment outranking the file, which is correct behaviour, not a bug; the log confirms the value came from os.environ. “CLI flag has no effect” means the flag was wired into the wrong position in the source order, so it is being overridden by something above it. And “default leaks to production” means a required key was unset in the target environment and fell through to its default — the fix is a CI check that asserts every required key resolves from a source above the defaults layer, so a missing production variable fails the build rather than silently running on a development default. The through-line across all four symptoms is that the resolution is deterministic and observable — nothing about it is mysterious once you log the source — so “debugging precedence” reduces to reading the source log and checking a variable’s position in the order.

Four precedence symptoms and their fixes A value differing across environments means diff the source log, an ignored .env value is correct precedence, a CLI flag with no effect is in the wrong position, and a default leaking to production means add a CI check. symptom fix value differs across envs diff the source log .env value ignored correct — env outranks .env CLI flag has no effect it is in the wrong position default leaks to production CI check: required resolves above defaults
Most precedence questions are answered by the source log; a required-key CI check catches leaked defaults.

Frequently asked questions

What is the standard configuration precedence order in Python?

Highest to lowest: CLI flags, then OS environment variables, then the .env file, then a checked-in config file, then hard-coded defaults. The first source that supplies a value wins. The order is not a rule you must memorise so much as a consequence of one principle — the more deliberately a value was set, the more it is respected — so a flag beats an injected variable, which beats a local file, which beats a committed baseline, which beats a fallback default. Encode that order in one place and it holds identically in every environment.

Why does pydantic-settings ignore my .env value?

pydantic-settings gives OS environment variables higher priority than the .env file by default. If the key is already set in the real environment, that value wins — which is correct for production parity. This is the same environment-above-file ordering that keeps an injected production secret winning over a committed .env, so the behaviour you are seeing locally is the behaviour that protects you in production. If you genuinely want the .env to win for a particular local workflow, unset the shell variable rather than reordering the sources — the ordering itself should stay fixed.

How do I make precedence identical between local and production?

Express the order once in a single resolver or settings model and run that same code everywhere. Never branch the precedence logic on an ENV string; only the values should differ. The moment you write if ENV == "prod": ... around how a value is resolved, you have created two precedence policies that can diverge, which is the exact bug this discipline prevents. Keep the resolution logic environment-agnostic — the same argument order, or the same settings-model source order, in every environment — and let the inputs (which layers carry values) be the only thing that changes. That single constraint is what makes local behaviour a reliable predictor of production behaviour.

Key takeaways

The invariant: one declared order, the first non-empty source wins, and that order is byte-for-byte identical in every environment. Encode it once and log the winning source so drift is visible. Precedence is fundamentally about determinism: for any key, the same source must win everywhere, and the way to guarantee that is to express the order in a single place — the argument order to a resolver, or the source order of a settings model — with no if ENV == ... branching anywhere. The conventional order (CLI, environment, .env, config file, defaults) is not arbitrary; it tracks how deliberately a value was set, so the more someone went out of their way to set a value, the more it is respected.

Two habits make the invariant enforceable. Log the resolved source of each key at startup, so a wrong value becomes a lookup rather than a hunt and cross-environment drift is visible in a diff. And add a CI check that every required key resolves from a source above the defaults layer, so a missing production variable fails the build rather than silently running on a development default. Get the order declared once, keep .env below the environment so injected secrets always win, and keep secrets out of committed file defaults — and the “works on my machine” class of bug simply cannot occur. If you take away one operational habit from this guide, make it the source log: almost every precedence incident is diagnosed the moment someone can see which layer supplied the value, and almost none are diagnosed quickly without it. Everything else here — the fixed order, the single resolver, the required-key check — exists to make that log boring to read. A boring startup log — every key resolving from the layer you expected, in the order you declared — is the clearest possible evidence that the configuration layer is doing its job and will keep doing it after the next deploy.