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-settings already ranks OS environment above the .env file — do not re-implement that order on top of it.
  • argparse defaults are indistinguishable from explicit values unless you set default=None and 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 ENV string.

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.