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 fixes the order to one deterministic chain. It builds on the configuration precedence rules cluster.
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. Reorder these three lines during a refactor and the precedence silently changes.
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 different intents.
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. Empty strings are treated as unset, so an accidental PORT="" does not shadow the file.
Gotchas & version-specific behaviour
pydantic-settingsalready ranks OS environment above the.envfile — do not re-implement that order on top of it.argparsedefaults are indistinguishable from explicit values unless you setdefault=Noneand filter, as above.- Environment variables are always strings; coerce after resolving precedence, not before.
- A
PORT=""in CI is “set to empty,” which is almost never what you want — validate non-empty.
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.
Conclusion
One declared chain — CLI, then environment, then file, then default — with empty strings treated as unset, eliminates precedence ambiguity. For the broader model and the pydantic-settings source order, return to Configuration Precedence Rules.