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.

The factor III test for what counts as config Values that vary between deploys, such as database URLs, credentials, and per-environment endpoints, belong in the environment; values identical in every deploy belong in code. does this value vary between deploys? yes → environment database URLs and hostnames credentials and API keys per-deploy endpoints and quotas feature toggles that differ by deploy injected at run time no → code route tables and URL patterns framework wiring and middleware constants identical everywhere business rules and schemas baked into the artifact
Factor III's test is variance across deploys — not whether a value looks like configuration.

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.

Environment branching versus injected values Branching on hostname or an ENV string enumerates environments inside the codebase, while injecting values keeps one code path that works for any number of deploys. code enumerates environments if ENV == "prod": url = ... elif ENV == "staging": url = ... else: url = "localhost" new deploy → code change + release production URL lives in git history deploy supplies the value url = settings.database_url one code path, any number of deploys new deploy → set a variable nothing sensitive in the repo
Branching makes environments a closed set in code; injection makes them an open set in the deploy.

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.

Environment above files keeps injected values authoritative When the environment sits above the .env file and the config file, an injected production value always wins; inverting the order lets a committed file override the deploy. what ships in the artifact vs. what the platform injects OS environmentinjected by the platform · never in the image .env filelocal only · seeds gaps · never committed config filenon-secret shared baseline · in the image field defaultssafe fallbacks only · never secrets
Only the top layer is injected at run time — which is exactly why it must outrank everything shipped in the image.

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.

Config enters at the release stage, not the build stage One build produces a config-free artifact; each release combines that artifact with a per-deploy config set; the run stage executes the release. build artifact · no config release artifact + config set environment variables enter here run same artifact, any deploy Promotion swaps the config set, never the artifact — which is what makes rollback reproducible.
Config enters at release. A build that knows its environment cannot be promoted without rebuilding.

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.

Failing at startup versus failing at first request Validating config at import time fails the rollout, while lazy loading lets an incomplete deploy pass its readiness probe and fail every request afterwards. lazy load container starts readiness probe passes joins the load balancer every request 500s rollout reported healthy validate at import container starts settings model validates exit 78 with field names rollout fails immediately no traffic ever served
Validate at import so an incomplete deploy fails the rollout instead of passing its probe and failing every request.

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_CONFIG JSON blob — technically stores config in the environment but defeats the point: individual keys can no longer be set, audited, or rotated independently.
  • An ENV or APP_ENV variable 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, ENV strings, or build flags.
  • OS environment variables outrank .env and 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.