Configuration the Twelve-Factor Way

Read configuration from the environment, keep defaults safe, and refuse to start when a required setting is missing.

SyntaxPORT = int(os.environ.get("PORT", "8000")) # os.getenv("PORT", "8000") is identical

Configuration is everything that differs between your laptop and production: database URLs, API keys, ports, feature flags, log levels. The twelve-factor rule is to keep it in the environment rather than in the code, so one build artefact runs in every environment and no secret is ever committed.

Reading it

os.environ is the mapping; os.getenv(key, default=None) is a thin wrapper, so os.getenv("PORT", "8000") and os.environ.get("PORT", "8000") are the same call written two ways. Use os.environ["DATABASE_URL"] when there is no acceptable default — the KeyError is the point.

Every value is a str. int(os.environ["PORT"]) for numbers, and parse flags explicitly, because bool("0") and bool("false") are both True — casting a flag gives you a setting that is on no matter what you set it to.

Safe defaults

A default is only acceptable when the safe choice is obvious: LOG_LEVEL=INFO, PORT=8000, DEBUG off. A default database URL pointing at localhost, or a default that disables authentication, is a production incident waiting for someone to forget a variable.

Fail at startup, not at midnight

Load and validate the whole configuration once, at import time, and raise listing every missing key together. The alternative is os.getenv("STRIPE_KEY") returning None at boot, travelling through the system as None, and failing as an unexplained 401 the first time someone checks out. Deploy tooling notices a container that will not start; it does not notice one that starts broken.

For non-secret file-based settings, tomllib (added in 3.11) reads TOML from a file opened in binary mode, and the environment still wins for anything that differs per deployment.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Running the same container image in staging and production with different variables.
  • Making a deploy fail immediately when someone forgets to set a new secret.
  • Removing a hard-coded localhost database URL from a service that is about to ship.
  • Turning a debug flag on in staging without a code change.

More examples

One settings object, built once

The shape to copy into a real service. Because it runs at import, a missing variable breaks the container's first start, where your deploy pipeline can see it — and frozen means no code path can mutate configuration halfway through a request.

Example · python
import os
from dataclasses import dataclass

@dataclass(frozen=True)          # frozen: nothing reconfigures itself at runtime
class Settings:
    database_url: str
    api_key: str
    port: int = 8000
    debug: bool = False

def _flag(name, default="0"):
    return os.environ.get(name, default).strip().lower() in ("1", "true", "yes", "on")

def load_settings():
    missing = sorted(k for k in ("DATABASE_URL", "API_KEY") if not os.environ.get(k))
    if missing:
        raise RuntimeError("missing environment variables: " + ", ".join(missing))
    return Settings(
        database_url=os.environ["DATABASE_URL"],
        api_key=os.environ["API_KEY"],
        port=int(os.environ.get("PORT", "8000")),
        debug=_flag("DEBUG"),
    )

settings = load_settings()       # module level: import fails, so the process fails

The same thing with pydantic-settings

Worth the dependency on anything bigger than a script: you get types, coercion and one readable error. The env_file line is the part to be careful with — it is easy to let a .env become the real source of production config, which is exactly what this lesson is against.

Example · python
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    # Reads DATABASE_URL, API_KEY, PORT, DEBUG from the environment. A field
    # with no default is required, and a missing one raises ValidationError
    # naming every absent key at once.
    database_url: str
    api_key: str
    port: int = 8000
    debug: bool = False                      # "0"/"false"/"no" parse correctly here
    log_level: str = Field(default="INFO")

    model_config = SettingsConfigDict(env_file=".env", env_prefix="")

settings = Settings()     # raises at import if the environment is incomplete

# .env is a local-development convenience. It belongs in .gitignore, and the
# deployed container should get real environment variables, not a file.

Non-secret defaults in a file, secrets in the environment

The split that works in practice: structure and tuning in a version-controlled file, credentials and per-environment values in the environment. tomllib is read-only by design — there is no dump, so use a library if you need to write TOML back out.

Example · python
import os
import tomllib                  # standard library since Python 3.11
from pathlib import Path

# tomllib.load requires a BINARY file object; opening in text mode raises
# TypeError, which is the first thing everyone hits.
with Path("config.toml").open("rb") as fp:
    file_config = tomllib.load(fp)

# config.toml holds things that are safe to read in a repository:
#   [service]
#   name = "orders"
#   timeout_seconds = 10
settings = {
    "name": file_config["service"]["name"],
    "timeout": float(file_config["service"]["timeout_seconds"]),
    # The environment overrides the file, and secrets only ever come from here.
    "api_key": os.environ["API_KEY"],
}
print(settings["name"], settings["timeout"])

Discussion

  • Be the first to comment on this lesson.