12-factor config precedence in Python
The 12-factor app says “store config in the environment,” but a real service also has .env files, config files, and defaults. Reconciling them with the 12-factor ideal means putting the environment on top of a deterministic ladder — and, more importantly, applying the doctrine’s actual test for what belongs there. This page works from the factor-III definition outward, extending Configuration Precedence Rules.
The definition is narrower and more useful than the slogan. Factor III defines config as everything that is likely to vary between deploys, and its litmus test is blunt: could you open-source the codebase right now without leaking credentials? If yes, the separation is clean. Notice what the definition excludes — internal routing tables, framework wiring, and anything identical in every deploy is not config by this standard and can happily live in code. The rule is about variance across deploys, not about which values happen to be strings.
Problem 1: config baked into code
# ANTI-PATTERN: config that differs per environment lives in code
if socket.gethostname().startswith("prod"): # config branching on hostname
DATABASE_URL = "postgres://prod-db/app"
This violates factor III — config that varies between deploys must live in the environment, not in if branches. The damage goes beyond untidiness. The production database URL is now in version control, visible to everyone with repository access and preserved in history even after it is removed. The branch is untestable in any meaningful way, because exercising the production path requires a machine whose hostname starts with prod. And adding a fourth environment means editing, reviewing, and redeploying code to change a value that should have been a deploy-time input.
The hostname check is only the most visible form. if os.environ["ENV"] == "production" is the same violation wearing a more respectable disguise: the code still enumerates the environments it knows about, so a new one behaves like whatever the else branch does. The 12-factor formulation is deliberately strict about this — there is no place for a per-environment branch at all, because config is a many (one value per deploy) while code is a one (identical everywhere). Any construct that maps environment names to values inside the codebase reintroduces the coupling the factor exists to remove, and it scales badly: every new region, tenant, or preview environment requires a code change.
Problem 2: the environment not actually winning
# ANTI-PATTERN: file config overrides the environment
DATABASE_URL = file_config.get("database_url") or os.environ["DATABASE_URL"]
Here the checked-in file outranks the environment — the opposite of the 12-factor rule. Teams arrive at this ordering honestly: the file is the thing they edit during development, so reading it first feels natural, and everything works until the first deploy where the orchestrator injects a value that the file also happens to define. Then the injected production credential loses to a stale committed one, and the failure is the worst possible shape — the variable is demonstrably set in the container, env | grep DATABASE_URL shows the right value, and the application still connects somewhere else.
The 12-factor ordering exists precisely to make that impossible. The environment is the one layer that the deployment platform controls and that never ships inside the artifact, so putting it above every file guarantees that whatever the platform injects is what the process uses. Files below it can seed local gaps and hold non-secret shared defaults, and that is a genuinely useful role — but they can never override a deliberate deploy-time decision. Invert the two and you have built a system where a file that someone forgot to delete can silently redirect production traffic.
Secure implementation
# config/twelve_factor.py
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
# Environment is authoritative; .env only seeds local gaps; defaults are last.
model_config = SettingsConfigDict(env_file=".env", extra="forbid")
database_url: str # required from the environment
log_level: str = "INFO" # default is the lowest-priority fallback
settings = Settings()
# pydantic-settings already ranks: init > OS env > .env > defaults — exactly 12-factor.
pydantic-settings encodes the 12-factor order natively: OS environment variables outrank the .env file, which outranks defaults. No hostname branching, no file overriding the environment. Two annotations in that class carry the doctrine. database_url: str with no default makes the value required from the environment — the process refuses to start when the deploy forgot to inject it, which is the run-time enforcement of “config lives in the environment.” A default here would quietly turn a deployment mistake into a service talking to the wrong database, so the absence of a default is a deliberate design decision rather than an omission.
extra="forbid" enforces the other direction: an environment variable matching the model’s prefix but no declared field is an error rather than a silently ignored typo. DATABSE_URL=... fails loudly instead of leaving database_url unset. Together the two settings turn the model into a contract between the deploy and the code — the deploy must supply exactly the declared keys, no more and no fewer, and any drift surfaces at startup rather than at the first request that needs the value.
Build, release, run: where config enters
Factor V — strict separation of build, release, and run — is what makes the precedence order operationally meaningful, and it is the factor most often skipped. The build stage turns source into an artifact and must contain no deploy-specific config; the release stage combines that artifact with a config set for one particular deploy; the run stage executes the release. Config enters at release, never at build. That single constraint is why the same image can be promoted from staging to production untouched: promotion swaps the config set, not the artifact.
Practically, this means no ARG ENV=production baked into a Dockerfile layer, no per-environment image tags like app:prod, and no build-time template rendering that writes credentials into the image. If your CI produces a different image for staging than for production, you are not testing what you ship — the artifact that passed staging is not the artifact serving traffic. Building once and injecting at release is also what makes rollback trustworthy: the previous release is a known artifact plus a known config set, and re-running it reproduces exactly the prior behaviour.
Failing fast when the deploy is incomplete
The 12-factor position on a missing variable is unambiguous: the process should not start. A service that boots with a half-configured settings object trades an obvious failure at startup for an obscure one later, usually at the first request that touches the missing value — by which time the deploy is marked healthy, traffic is flowing, and the error surfaces as a 500 rather than as a failed rollout.
# config/twelve_factor.py (continued)
import sys
from pydantic import ValidationError
def load_settings() -> Settings:
try:
return Settings()
except ValidationError as exc:
# Print the missing/invalid field names — never the values.
for err in exc.errors():
field = ".".join(str(p) for p in err["loc"])
print(f"config error: {field}: {err['msg']}", file=sys.stderr)
raise SystemExit(78) # EX_CONFIG — the deploy, not the code, is wrong
settings = load_settings()
Two details make this behave well in an orchestrator. Exiting with a distinct code — 78 is the conventional EX_CONFIG — lets a supervisor or deployment pipeline distinguish “this deploy was configured wrong” from “the application crashed,” which are different alerts with different owners — the first pages whoever owns the deployment pipeline, the second pages whoever owns the service, and conflating them wastes the first ten minutes of every configuration incident on working out which team is even looking at the right thing. And the handler prints field names and messages but never values, because a validation error on a database URL that echoes the URL will paste a credential into a log aggregator that many more people can read than can read the secret store.
The failure also needs to happen at import time, before the health endpoint can answer. If settings are loaded lazily on first use, a container with a missing variable passes its readiness probe, joins the load balancer, and then fails every request — the deployment looks successful while the service is broken. Constructing the settings object at module import, as the snippet does, makes an incomplete deploy fail the rollout instead, which is exactly where a configuration mistake should stop.
Gotchas & version-specific behaviour
- Factor III wants config that varies between deploys in the environment; truly constant values can stay as defaults or plain code.
- Secrets are config too — keep them in the environment or a secret store, never in code.
- One codebase, many deploys (factor I/X): the same image reads different environment values; a per-environment image tag is a smell.
extra="forbid"enforces that every environment supplies exactly the expected keys, turning a typo into a startup failure.- Grouped config — a single
SERVICE_CONFIGJSON blob — technically stores config in the environment but defeats the point: individual keys can no longer be set, audited, or rotated independently. - An
ENVorAPP_ENVvariable is fine for labelling logs and metrics; it stops being fine the moment code branches on it to choose a value.
The grouped-config point deserves expanding, because it is the most common way a codebase satisfies the letter of factor III while missing its intent. Packing every setting into one SERVICE_CONFIG='{"db": {...}, "cache": {...}}' variable does put config in the environment, but it collapses the granularity that made the environment useful: you can no longer rotate a single credential without rewriting the whole blob, an audit log shows one opaque change rather than which key moved, and the orchestrator’s own secret handling — which works per variable — has nothing to grip. Keep one variable per setting, name them predictably with a shared prefix, and let the settings model assemble the structure on the way in.
The same caution applies to per-environment .env files committed to the repository — .env.staging, .env.production, and friends. They look like config separated from code, but they ship inside the artifact, which puts them on the wrong side of the build/release boundary and reintroduces the enumeration problem: the repository once again knows the full list of environments. A single untracked .env for local development, plus injected variables everywhere else, is the shape that actually holds.
Production parity checklist
- No config branches on hostname,
ENVstrings, or build flags. - OS environment variables outrank
.envand defaults. - The same image runs in every environment with different injected values.
- Secrets come from the environment or a managed store.
- Required keys validated at startup, with no default to fall back on.
- Config enters at release, so the artifact that passed CI is the artifact that serves traffic.
A quick way to audit an existing service against this list is to try the factor-III thought experiment literally: skim the repository as though you were about to publish it. Every hostname, bucket name, account identifier, and endpoint you find is a candidate violation, and each one is a value that would otherwise have to be edited and redeployed rather than injected. The exercise usually turns up a small number of genuine offenders — a fallback URL in a settings module, a per-environment block in a Compose file, a hard-coded region in a client constructor — and each is a few minutes’ work to convert into a declared field with no default.
Key takeaways
The 12-factor ideal and a precedence ladder agree: the environment wins, files seed gaps, defaults are last, and nothing varies in code. The doctrine adds two things a bare ordering does not. It gives you a test for what belongs in the environment at all — does this value vary between deploys? — and it fixes when config enters the system, at release rather than at build, which is what lets one artifact serve every deploy. Encode the order once, declare required keys with no defaults, and forbid extras, and the code stops knowing which environment it is running in. That last property is the one worth optimising for: a codebase that cannot name its environments is a codebase where adding a region, a tenant, or a preview deploy costs a variable rather than a release, and where the artifact you tested is provably the artifact you shipped. For the full source-order model, see Configuration Precedence Rules.