Several articles on this site — Connecting to PostgreSQL from Python, API Authentication Patterns — include some version of the same callout: credentials belong in configuration, not source code. That advice has been repeated rather than explained in depth because it did not yet have a home of its own. This is that home.
Config lives in the environment, not the code#
The twelve-factor app's config principle states this plainly: config is what varies between deploys — a database host, an API base URL, a feature flag — and code is what does not. A project structured this way could be made public at any moment without exposing a single credential, because none of them were ever written into the code to begin with:
import os
DB_HOST = os.environ["DB_HOST"]DB_PASSWORD = os.environ["DB_PASSWORD"]This already satisfies the "not hardcoded" bar every earlier callout on this
site was making. It has a different problem: os.environ["DB_PASSWORD"]
raises KeyError: 'DB_PASSWORD' wherever it happens to run first — possibly
deep inside a function called ten minutes into a scheduled job — rather than
immediately, clearly, at startup.
Validate configuration once, at startup#
A typed config object, built once when the process starts, turns "missing configuration" into a single clear failure before any real work begins:
from dataclasses import dataclass
@dataclass(frozen=True)class Config: db_host: str db_password: str poll_interval: int = 10
def load_config() -> Config: return Config( db_host=os.environ["DB_HOST"], db_password=os.environ["DB_PASSWORD"], poll_interval=int(os.environ.get("POLL_INTERVAL", 10)), )Every required environment variable
is read in one place, at one time. A deployment missing DB_HOST fails
immediately with a clear KeyError naming exactly what is missing, instead
of failing confusingly whenever the code path that needs it first runs.
For a larger config surface, Pydantic's
settings model does the same thing with type coercion and validation rules
built in, rather than hand-written conversions like int(...) above — worth
the extra dependency once a config object grows past a handful of fields or
needs real validation beyond "does this string exist."
Config and secrets are not quite the same thing#
A config value — a poll interval, a feature flag, a base URL — is safe
to log, safe to commit to a .env.example template, and only inconvenient
if wrong. A secret — a password, an API key, a signing key — must never
be logged, never committed, and often benefits from a dedicated secrets
manager (cloud-provider secret stores, HashiCorp Vault)
rather than a plain environment variable once a team grows past a handful
of people who all need access.
A .env file is still a file
.env files are a common convenience for local development, loaded into
os.environ at startup by a small library such as
python-dotenv. They are not a secrets manager —
a .env file committed to version control by accident is exactly the
"credentials in source" problem this whole article exists to avoid, just
one file removed from the literal code. Add .env to .gitignore before
the first secret ever goes into one.
Common mistakes#
Reading os.environ scattered throughout the codebase#
Every direct os.environ[...] call outside one central config-loading
function is a place a missing variable can fail unpredictably, and a place
someone editing the code later has to remember to check. Load and validate
configuration once; pass the resulting object around like any other
dependency.
Giving a required secret a silent default#
os.environ.get("DB_PASSWORD", "") turns a missing, genuinely required
secret into an empty string that fails much later, much less clearly — a
failed database connection instead of an immediate, obvious configuration
error. Use os.environ[...] (which raises) for anything that must be
present; reserve .get(..., default) for values that are truly optional.
Committing a .env file "just for now"#
The file that was committed temporarily to unblock a deploy is the file that stays in git history permanently, readable by anyone with repository access, indefinitely — rotating the leaked credential afterward does not remove it from history.
Further reading#
- The Twelve-Factor App: Config — the principle this whole article expands on.
- Pydantic — typed settings validation for a config surface larger than a plain dataclass comfortably handles.
- Environment variable — the underlying OS mechanism every approach above reads from.