Config precedence: CLI vs environment vs file in Python
When --port 9000 on the command line, PORT=8080 in the environment, and port: 8000 in config.yaml all set the same value, which one runs? If you cannot answer instantly and identically for every environment, you have a precedence bug waiting to surface in production. This page narrows the question to the three-way conflict that actually causes incidents — flag versus variable versus file — and to the one distinction that decides most of them: whether a source supplied a value at all. It builds on the broader configuration precedence rules guide.
The three-way conflict is worth isolating because it is where the two failure modes below live. Every other layer in a full precedence stack behaves the same way, but flags, variables, and files are the three that people actually set by hand, on the same key, on the same day — and the three that disagree about what “not set” means.
Problem 1: order decided by accident
# ANTI-PATTERN: last writer wins, by luck of import order
port = config_file.get("port", 8000)
port = int(os.environ.get("PORT", port))
if args.port:
port = args.port
This happens to put CLI on top, but the order is implicit — it lives in the vertical position of three statements rather than in any declaration. Reorder these lines during a refactor, move the environment read into a helper that gets called earlier, or add a fourth source in the middle, and the precedence silently changes with no test failing. Nothing in the code says “CLI outranks environment”; the reader has to reconstruct that fact by tracing assignments, which is exactly the kind of reasoning that goes wrong at 3 a.m.
There is a subtler bug hiding in the same snippet. if args.port: is a truthiness test, so --port 0 — a legitimate value meaning “pick an ephemeral port” — is discarded and the environment value survives. The same trap catches --workers 0, --retries 0, and every flag whose valid range includes zero or an empty string. Truthiness is not the same question as “did the operator supply this?”, and conflating them produces bugs that only appear for specific values, which makes them hard to reproduce and easy to dismiss as flaky.
Problem 2: empty string treated as “set”
# ANTI-PATTERN: PORT="" overrides the file with nothing
port = os.environ.get("PORT") or config_file["port"] # "" is falsy, but...
host = os.environ.get("HOST") or config_file["host"] # HOST="" silently falls through
Mixing or with empty strings makes “set to empty” indistinguishable from “unset” — two genuinely different intents. This matters more than it looks, because CI systems produce empty strings constantly: an unset pipeline variable interpolates to "", a docker run -e HOST with no value exports an empty string, and a .env line reading HOST= sets the key to empty rather than leaving it absent. In every one of those cases the environment layer has the key, so a naive key in os.environ check says “the environment set it” while or says “fall through to the file.” The two checks disagree, and which one your code happens to use decides the outcome.
The reason this bites in CI specifically and not locally is that developers set variables deliberately — you type HOST=db.local python app.py and the value is there — whereas pipelines set them by template expansion. A job definition containing HOST: ${DB_HOST} produces HOST="" when DB_HOST is undefined in that pipeline’s variable scope, not an absent HOST. The container starts, the key exists, the file’s value is shadowed by nothing at all, and the service connects to an empty host. Nothing in the logs says “empty”; you see a connection error against a blank address and start suspecting DNS.
The fix is to pick one definition of “supplied” and apply it in every layer. For string-valued configuration, treating empty as unset is almost always right: nobody deliberately configures an empty hostname, and the empty value nearly always originates from an interpolation accident rather than an operator decision. Whichever rule you choose, apply it inside the resolver so all three sources are judged identically — the failure mode is not the rule, it is applying different rules to different layers.
Secure implementation
# config/precedence.py
import argparse
import os
import yaml
def load(path="config.yaml") -> dict:
with open(path) as fh:
return yaml.safe_load(fh) or {} # safe_load, never yaml.load
def resolve(key: str, cli: dict, env: dict, file: dict, default=None):
# 1. CLI flags 2. environment 3. config file 4. default
for source in (cli, env, file):
value = source.get(key)
if value is not None and value != "": # distinguish unset from empty
return value
return default
args = argparse.Namespace(port=None) # populated by argparse
cli = {k: v for k, v in vars(args).items() if v is not None}
PORT = int(resolve("port", cli, os.environ, load(), default=8000))
The order is the argument order to resolve, declared once and visible on a single line. Empty strings are treated as unset by the same test in every layer, so an accidental PORT="" does not shadow the file, and --port 0 survives because the test is is not None, not truthiness. Two details carry most of the weight here. First, argparse parsers must use default=None for every flag that participates in precedence — an argparse default is indistinguishable from an operator-supplied value once parsing finishes, so a default of 8000 would make the CLI layer claim to have supplied a value on every single run and permanently mask the environment. Filtering None out of vars(args) reconstructs the “did the operator actually type this?” signal that argparse erases.
Second, coercion happens after resolution, not inside it. resolve deals in raw values and returns whichever one wins; int(...) converts once at the call site. Coercing inside each source would mean writing the conversion three times and getting three subtly different error messages when the value is malformed. Resolving first and coercing once means a bad PORT=abc fails in exactly one place with one message, and the traceback points at the key rather than at the layer.
It is worth noting what resolve deliberately does not do. It does not merge. If the CLI supplies port and the file supplies host, both survive, because they are different keys resolved independently — but if the file supplies a nested database mapping and the environment supplies DATABASE_URL, the resolver picks one whole value and discards the other rather than deep-merging them. That is the right default: merging two sources of structured configuration produces a result that exists in neither source, which is nearly impossible to reason about when something goes wrong. Flatten structured configuration to individual keys before it reaches the chain, and every value in the running process is traceable to exactly one source.
Gotchas & version-specific behaviour
pydantic-settingsalready ranks OS environment above the.envfile — do not re-implement that order on top of it, or you end up with two orders that can disagree after an upgrade.argparsedefaults are indistinguishable from explicit values unless you setdefault=Noneand filter, as above.argparse.SUPPRESSachieves the same thing by omitting unsupplied flags from the namespace entirely.- Environment variables are always strings; coerce after resolving precedence, not before, so
"0"and0are not judged by different rules. - A
PORT=""in CI is “set to empty,” which is almost never what you want — validate non-empty at the resolver, not at each call site. - Boolean flags need care:
--verbosewithaction="store_true"defaults toFalse, which looks like a supplied value. Useaction=argparse.BooleanOptionalActionwithdefault=Noneso “not passed” stays distinguishable from “passed as false.”
Reading that table the other way round is a useful debugging habit. When a value is wrong, the first question is not “which source should have won?” but “which source did win?” — and if the startup log answers that, the rows above tell you almost immediately whether the winner claimed the key legitimately or by accident. A layer that wins when you expected it to abstain is nearly always an argparse default or an empty interpolation; a layer that abstains when you expected it to win is nearly always a truthiness test discarding a valid falsy value. Both are one-line fixes once you know which of the two you are looking at, which is the entire argument for logging the source in the first place.
Pinning the chain with a table test
Precedence is unusually easy to test, because the resolver takes its sources as plain mappings. You do not need to spawn a process, patch os.environ, or write a config file to disk — you construct three dictionaries and assert the winner. That makes a table test the natural shape, and a table test is what stops a future refactor from quietly reordering the chain.
# tests/test_precedence.py
import pytest
from config.precedence import resolve
CASES = [
# cli, env, file, expected
({"port": 9000}, {"port": "8080"}, {"port": 8000}, 9000), # flag wins
({}, {"port": "8080"}, {"port": 8000}, "8080"), # env wins
({}, {}, {"port": 8000}, 8000), # file wins
({}, {}, {}, 8000), # default
({}, {"port": ""}, {"port": 8000}, 8000), # empty is unset
({"port": 0}, {"port": "8080"}, {"port": 8000}, 0), # zero is a value
]
@pytest.mark.parametrize("cli, env, file, expected", CASES)
def test_precedence(cli, env, file, expected):
assert resolve("port", cli, env, file, default=8000) == expected
Six rows cover every behaviour this page argues for, and each row fails with a message that names the exact scenario. The last two are the ones that earn their keep: they encode the “empty is unset” and “zero is supplied” decisions as executable statements rather than comments, so a well-meaning change from value is not None and value != "" to a plain truthiness check fails immediately instead of shipping. Add a row whenever you add a source, and the table doubles as the chain’s documentation.
One thing the table cannot catch is a second copy of the chain living elsewhere in the codebase. If a worker process, a management command, or a migration script builds its own resolution logic, the table passes while the services disagree. A quick grep for os.environ.get outside the config package, run in CI as a lint rule, keeps the single-chain property honest — the test proves the chain is correct, and the lint proves it is the only one.
Production parity checklist
- Declare the precedence order in exactly one function and import it everywhere.
- Log the winning source per key (not the value) at startup.
- Add a CI assertion that every required key resolves above the defaults layer.
- Keep the order identical across local, CI, staging, and production.
- Never branch the order on an
ENVstring. - Set every participating
argparseflag todefault=Noneso unsupplied flags stay invisible to the resolver.
The checklist reduces to two verifiable properties. The order is declared once, so there is exactly one place to read it and one place to change it; and the winning source is logged, so a wrong value is a lookup rather than an investigation. A test that constructs the three source mappings by hand and asserts the winner for a representative key — flag beats variable, variable beats file, file beats default, empty is skipped, zero is honoured — pins all of it in about a dozen lines and fails loudly the first time someone reorders the chain. Everything else on the list — the CI assertion, the ban on branching, the argparse rule — exists to protect one of those two properties, so if you are triaging which item to adopt first, adopt the single declared chain and the source log together and add the rest as the service grows.
Key takeaways
One declared chain — CLI, then environment, then file, then default — with empty strings treated as unset and is not None rather than truthiness deciding “supplied,” eliminates the three-way ambiguity. The bugs in this space are rarely about the order itself; they are about a layer wrongly claiming to have supplied a value, whether through an argparse default, a falsy zero, or an empty CI variable. Fix the “was this supplied?” test once, in the resolver, and the order takes care of itself — the ordering half of the problem is the easy half, and it stays solved as long as the chain is declared in exactly one place and covered by a table test. For the broader model and the pydantic-settings source order, return to Configuration Precedence Rules.