Environment Variables & os.environ

The classic production failure is a service that boots with DEBUG=False set in the environment yet runs in debug mode anyway, because bool("False") is True. Environment variables are the 12-factor baseline for configuration, but os.environ hands you raw strings with no typing and no safety net whatsoever. This page is the disciplined way to read them without falling into that trap.

This technique sits at the very front of the configuration pipeline: environment variables are the first structured source the process sees, and everything downstream — precedence resolution and pydantic-settings validation — depends on reading them correctly.

The core problem is that os.environ is a dict[str, str] — every value is a string, and Python does nothing to convert it to the type your code actually needs. That single fact is behind almost every environment-variable bug: the boolean that is always true because "False" is a non-empty string, the port that crashes deep inside a request handler because someone accidentally typed a letter into it, the list that never splits because nobody parsed the commas and the whole thing was treated as one long value. Each of these is the same underlying mistake — trusting a string to behave like the type you wanted — and each is invisible until the specific input that exposes it appears. Reading the environment safely means reimposing types on the way in, and doing it in a way that fails loudly and early when a value is wrong rather than silently and late.

This page builds a small set of typed accessors — get_bool, get_int, get_list — that each do two things: coerce the raw string to the right type, and fail fast with a clear message when it cannot. A required value that is missing stops the process at startup with a clear message; a malformed value raises with both the variable name and the offending input named. These helpers are the manual version of what a pydantic-settings model does automatically, and understanding them makes the model’s behaviour obvious rather than magical.

It is worth being clear about when to reach for the hand-written accessors versus a full settings model. For a small script or a service with a handful of variables, the accessors on this page are perfectly adequate — they are a few lines, have no dependencies, and make the coercion and failure handling explicit. As the number of typed fields grows, or when you need domain validation, secret masking, or a single object to pass around, the accessors start to feel like a hand-rolled version of something that already exists, and that is the signal to adopt a settings model. The two are not in tension: the accessors teach you exactly what the model automates, so the transition from one to the other is natural rather than a leap into the unknown. Either way, the principle is identical — the environment is a dict[str, str], and safe configuration means reimposing types and requiredness on the way in.

Secure implementation

# config/env.py
import os

def get_bool(key: str, default: bool = False) -> bool:
    raw = os.environ.get(key)
    if raw is None:
        return default
    return raw.strip().lower() in {"1", "true", "yes", "on"}   # explicit truthy set

def get_int(key: str, default: int | None = None) -> int:
    raw = os.environ.get(key)
    if raw is None:
        if default is None:
            raise SystemExit(f"Missing required integer env var: {key}")
        return default
    try:
        return int(raw)
    except ValueError as exc:
        raise SystemExit(f"{key} must be an integer, got {raw!r}") from exc

def get_list(key: str, sep: str = ",") -> list[str]:
    raw = os.environ.get(key, "")
    return [item.strip() for item in raw.split(sep) if item.strip()]

DEBUG = get_bool("DEBUG")             # "False" correctly becomes False
WORKERS = get_int("WORKERS", 4)
ALLOWED_HOSTS = get_list("ALLOWED_HOSTS")

Each accessor fails fast and loudly. A misconfigured integer never silently becomes a default; a missing required value stops the process at import time rather than during the first request.

Look at what each helper does with the raw string. get_bool checks the value against an explicit truthy set — {"1", "true", "yes", "on"}, case-insensitively — so "False", "0", and anything unrecognised become False, closing the truthiness trap for good. get_int attempts an int() conversion and, if it fails, exits with a message naming the variable and the bad value rather than letting a ValueError surface somewhere unrelated; a missing required integer (no default) exits at import. get_list splits on a separator and strips each item, returning an empty list for an unset variable rather than a list containing one empty string. The common thread is that each helper turns the string into the declared type or stops the process — there is no path where a malformed value flows onward untyped.

The SystemExit (via raise SystemExit(...)) is a deliberate choice for the failure path. Exiting with a message and a non-zero status at import time means the process dies before it binds a port, so an orchestrator sees the container fail its health check immediately and keeps the previous healthy instances serving. The alternative — a bare ValueError propagating — would also stop the process, but SystemExit lets you write a clean, operator-facing message (“PORT must be an integer, got ‘abc’”) rather than a stack trace, and it signals unambiguously that the cause is configuration, not a code bug. Reading configuration at import time, where these accessors run, is what makes the fail-fast behaviour possible: the reads happen as the module loads, so a bad value stops startup rather than surfacing on the first request that touches it — which, for a rarely-exercised code path, could be hours or days after the deploy, long after anyone would connect it to the configuration.

Typed accessors coerce a raw string or fail fast A raw environment string passes through get_bool, get_int, or get_list, which each produce a typed value or exit with a clear error, so no malformed value flows onward untyped. os.environ raw str get_boolget_intget_list typed value bool / int / list SystemExit names the bad value
Each accessor coerces the raw string to its type or exits naming the bad value — nothing untyped flows on.

The boolean trap, in depth

The single most common environment-variable bug deserves its own dissection, because it is subtle and its consequences are severe. It has two forms. The first is bool(os.environ["FLAG"]): bool() on a string returns True for any non-empty string, so FLAG=false, FLAG=0, and FLAG=no all evaluate to True — the flag is on no matter what the operator typed. The second is os.environ["FLAG"] == "true", which is stricter but brittle about case and vocabulary: FLAG=True (capital T) or FLAG=1 or FLAG=yes all evaluate to False, so a value the operator clearly meant as “on” reads as off.

Both stem from treating a string as if it carried the semantics of a boolean. The fix is to define, once, the exact set of strings that mean True{"1", "true", "yes", "on"}, compared case-insensitively after stripping whitespace — and treat everything else as False. That is precisely what get_bool does, and it is the same vocabulary a pydantic bool field uses. Encoding the rule in one accessor means every boolean flag in the codebase gets the same correct answer, and the “disabled feature that is actually enabled” incident simply cannot happen. This is the same reasoning that makes a typed bool field on a settings model safe: the model uses the identical explicit vocabulary, so whether you hand-roll get_bool or declare a bool field, the boolean is parsed correctly. What you must never do is compare the raw string yourself with bool() or an equality check, because both are wrong in ways that are easy to miss in review and expensive to discover in production.

Two broken boolean parses versus one correct one bool() on a string makes false truthy, string equality misses valid true values by case, but an explicit truthy set correctly maps the recognised strings to True and everything else to False. bool(os.environ["FLAG"]) "false" → True — any non-empty string feature stuck on env["FLAG"] == "true" "True" / "1" / "yes" → False misses valid true values get_bool: value in {"1","true","yes","on"} recognised strings → True, everything else → False case-insensitive, whitespace-stripped, correct
An explicit truthy set is the only reliable boolean parse; both hand-rolled forms are wrong in opposite ways.

Configuration reference

Pattern Type Default behaviour Security implication
os.environ["KEY"] str Raises KeyError if unset Fail-fast; preferred for required values
os.getenv("KEY", d) str | None Returns d if unset A wrong default can silently ship dev config
get_bool bool False unless explicitly truthy Prevents "False" truthiness bug
SecretStr(value) masked Hidden in repr/logs Keeps secrets out of tracebacks
override=False n/a Platform env wins over .env Stops local files clobbering injected secrets

The most consequential row is the first two together: os.environ["KEY"] versus os.getenv("KEY", default). The bracket form raises KeyError when the variable is unset, which is exactly what you want for a required value — the process refuses to start without it, loudly, at import. The getenv-with-default form returns the default silently, which is right only for a genuinely optional value whose default is safe in production. The mistake that causes incidents is using getenv with a default for a value that is not really optional — a database URL, a secret — because then a missing variable in production does not crash; it quietly runs on a development default. Choose the bracket form by default and reserve the defaulted form for values you would be genuinely comfortable shipping with that exact default in every environment, production included, with no one having set it explicitly.

Required versus optional reads of an environment variable os.environ bracket form raises KeyError for a missing required value, failing fast; os.getenv with a default returns the default silently, which is safe only for genuinely optional values. required value optional value os.environ["KEY"] KeyError if unset fail fast at startup os.getenv("KEY", d) returns d silently only safe if d is safe in prod
Use the bracket form for required values so a missing key fails fast; reserve the default form for the genuinely optional.

Reading ints, lists, and other typed values

Beyond booleans, the two other common coercions each have their own pitfalls. For integers, the naive int(os.environ["PORT"]) is close but incomplete: it raises ValueError on a bad value, but that error surfaces wherever the read happens — potentially deep in a request — with a message that does not name the variable. Wrapping it in get_int catches the ValueError, re-raises with the variable name and the offending value, and handles the missing-required case by exiting at import. The result is that a bad PORT produces “PORT must be an integer, got ‘abc’” at startup rather than an opaque ValueError mid-request.

For lists, the environment gives you one string, so you must decide a separator and split. get_list splits on a comma by default and strips each item, dropping empties so a trailing comma, a doubled comma, or an unset variable yields a clean list rather than one containing stray empty strings. The subtlety is that a comma is a common character inside values, so if your list items can themselves contain commas you need a different separator or a structured format like JSON — which is exactly the convention a pydantic list field uses. For most simple cases (allowed hosts, feature names) comma-splitting is fine, but choose the separator deliberately and document it so an operator setting the variable knows how it will be parsed. A common refinement is to accept both a delimited string and, if the value starts with [, a JSON array — giving operators the convenience of a,b,c while still supporting structured input — but for most services the plain comma split is enough and the extra branching is not worth it.

The broader lesson is that every non-string type needs an explicit coercion with an explicit failure mode, and centralising those coercions in a handful of named accessors means the rules are defined once and applied consistently. This is the manual version of what a validated settings model does for you — and when the number of typed fields grows past a handful, moving to a pydantic-settings model trades these accessors for declared field types that do the same coercion and failure handling automatically.

Coercing an int and a list from environment strings get_int turns "8080" into 8080 and exits naming a bad value, while get_list splits "a,b,c" on a separator and strips each item into a list. "8080" get_int 8080 (int) "a,b,c" get_list ["a","b","c"] bad → exit split + strip
Each type gets a named accessor that coerces the string and defines its own failure mode.

Deployment parity: local to production

  1. Local dev — define variables in an uncommitted .env and load them without overriding the shell (override=False); see .env File Management.
  2. CI — set non-secret variables in the pipeline config; pull secrets from the secret store at job runtime.
  3. Staging — inject the same keys via the orchestrator (Kubernetes env/envFrom) so the schema matches production exactly.
  4. Production — identical key names, secrets sourced from a managed store, validated by the settings model on boot.

The parity that matters is that the key names are identical everywhere — only their source and their values change. Locally the values come from an uncommitted .env; in CI from the pipeline config plus a secret store; in staging and production from the orchestrator’s injected environment. Because the accessors read the same key names in all four, a value that reads cleanly in one environment reads cleanly in the next, and the schema of “what variables this service needs” is the same everywhere. The one thing to hold constant is that names never diverge by environment — a DATABASE_URL in production must be DATABASE_URL in development too, not DEV_DATABASE_URL, or the parity is lost and testing one environment stops predicting another.

This is where reading configuration through consistent accessors pays off across the deployment pipeline. Because the same code reads the same key names everywhere, a value that a developer sets locally, a pipeline sets in CI, and an orchestrator injects in production all flow through the identical typed accessor and get the identical treatment — coerced to the right type, validated, and failed-fast on if wrong. The differences between environments collapse to which values are set and where they come from, never how they are read. That is the whole point of the twelve-factor “config in the environment” discipline: the built artifact is identical across environments, and only the injected values differ, so a bug in how a value is read is caught once, everywhere, rather than lurking in an environment-specific code path.

The local-development end deserves a word on the .env file. A .env is a convenience for setting variables during development without exporting them in your shell, and it is loaded with override=False so that any variable already present in the real environment wins. That ordering matters: it means a developer can override a single value for one run by exporting it, and — more importantly — that a .env file can never clobber a value the platform injected in production. The .env is a development convenience that sits below the real environment in precedence, which is exactly the relationship you want. The dedicated .env file management page covers loading it safely.

Identical key names across four environments Local dev reads a .env, CI reads pipeline config plus a secret store, staging and production inject via the orchestrator, all using the same key names read by the same accessors. Local — .envCI — pipeline + storeStaging — orchestratorProd — orchestrator same key names read by the same accessors
Only the source and values change per environment; the key names and accessors stay identical.

Security boundaries & guardrails

  • Never write a secret-bearing environment variable into a committed manifest or Dockerfile.
  • Wrap secret values in SecretStr the moment they are read so they cannot leak via repr().
  • Keep override=False when merging .env so platform-injected values always win.
  • Validate every typed read; a ValueError at startup beats a corrupt value in production.
  • Do not pass the full environment to subprocesses — hand them an explicit, minimal env dict.

Two of these guardrails are about keeping secrets from leaking through the environment itself. Never writing a secret into a committed manifest or Dockerfile keeps it out of version control and image layers, where it would live permanently; wrapping a secret in SecretStr the moment it is read keeps it out of repr(), logs, and tracebacks. The subprocess guardrail is the one people forget: by default subprocess inherits the parent’s entire environment, so a child process — a shell-out, a helper tool — receives every secret the parent holds, whether it needs them or not. Passing an explicit, minimal env dict to the subprocess hands it only what it actually needs, so a compromised or careless child cannot read credentials meant for the parent. And keeping override=False when loading a .env ensures a committed file can never clobber a secret the platform injected, preserving the precedence that keeps injected values authoritative.

The subprocess guardrail is worth dwelling on because it is invisible until it bites. When you call subprocess.run(...) without an env argument, the child inherits os.environ in full — every database password, API token, and secret the parent process holds. If that child is a shell command, a report generator, or any tool that logs its environment or can be influenced by an attacker, you have just handed it the parent’s entire credential set for no reason. Passing env={"PATH": os.environ["PATH"], "SPECIFIC_VAR": value} — an explicit, minimal dict of only what the child needs — closes that leak. It is a small habit with an outsized security benefit, and it is the environment-variable equivalent of least privilege: give each process only the configuration it actually requires and nothing more, so a leak in one process cannot expose the credentials of another.

The “never commit a secret-bearing variable” guardrail has a permanence to it that makes it non-negotiable. A secret written into a Dockerfile ENV line is baked into that image layer forever — deleting it in a later layer does not remove it, because the earlier layer still contains it, and anyone who can pull the image can extract it. A secret in a committed manifest lives in git history even after you delete the file. In both cases the remediation is not “remove the line” but “rotate the secret”, because it must be assumed compromised the moment it was committed. Keeping secrets out of committed files and image layers entirely — injecting them at runtime instead — is the only way to avoid that.

Five environment-variable guardrails Never commit a secret-bearing variable, wrap secrets in SecretStr on read, keep override=False, validate every typed read, and pass subprocesses a minimal env dict. Never write a secret-bearing variable into a committed manifest or Dockerfile Wrap secret values in SecretStr the moment they are read Keep override=False when merging .env so platform values win Validate every typed read; a ValueError at startup beats a corrupt value in prod Pass subprocesses an explicit, minimal env dict, not the whole environment
Keep secrets out of git, mask them on read, honour precedence, validate every read, and scope the subprocess environment.

Troubleshooting

  • KeyError at startup — a required variable is unset. This is the system working: set the key or move it to an optional accessor with a safe default.
  • Boolean always true — you are using bool(os.environ["FLAG"]); switch to get_bool with an explicit truthy set.
  • ValueError: invalid literal for int() — a numeric variable contains whitespace or a unit suffix; strip and validate in the accessor.
  • Secret appears in logs — something logged the raw environment or a config object; wrap secrets in SecretStr and never log os.environ.

The first two symptoms are the system working, not failing. A KeyError at startup for a required variable is fail-fast doing its job — the process refused to run without a value it needs, and the fix is to set the variable, not to swap in a defaulted read that would hide the problem. The “boolean always true” symptom is the classic bool(os.environ["FLAG"]) mistake, where every non-empty string is truthy; the fix is to route the value through get_bool with its explicit truthy set. The ValueError on an integer read means the accessor caught a non-numeric value — usually whitespace or a unit suffix — and the accessor should strip and report it clearly. And a secret in the logs means something serialized the raw environment or an unmasked config object; wrap secrets in SecretStr and add a test that asserts no secret appears in a rendered config or a serialized environment. That last test is cheap insurance: it renders both the config object’s repr and its serialized form and checks that no known credential value appears in either, so a field added later as a plain string instead of SecretStr fails the build rather than leaking silently weeks later.

Four environment-variable symptoms and their fixes A KeyError at startup means set the variable, a boolean always true means use get_bool, a ValueError means strip and validate in the accessor, and a secret in logs means wrap it in SecretStr. symptom fix KeyError at startup correct — set the variable boolean always true use get_bool, not bool() ValueError on int read strip and validate in accessor secret in logs wrap in SecretStr, never log env
Two symptoms are fail-fast working; the other two are a hand-rolled parse or an unmasked secret.

Frequently asked questions

Should I use os.getenv or os.environ to read configuration?

Use os.environ["KEY"] for required values so a missing key raises KeyError immediately at startup. Reserve os.getenv("KEY", default) for genuinely optional values where the default is safe in production. The deciding question is whether the service can correctly run without the value: if not, it is required and should fail fast when unset; if it can run with a sensible fallback in every environment, it is optional and a default is fine. The dangerous middle case is a value that is optional in development but required in production — model it as required, so a missing production variable crashes at boot rather than silently running on a development default.

Why does my boolean environment variable always evaluate to True?

Environment variables are always strings, and the string "False" is truthy in Python. Parse booleans explicitly — only treat "1", "true", "yes", and "on" (case-insensitive) as True.

Should secrets be passed as environment variables?

Short-lived, injected-at-runtime secrets in the process environment are acceptable, but never bake secrets into manifests or images. Wrap them in SecretStr once read and prefer a managed secret store for anything long-lived. The nuance is that an environment variable is visible to more than the process that reads it — docker inspect, /proc/<pid>/environ, child processes, and the orchestrator’s API can all see it — so an injected env var is a reasonable transport for a short-lived credential but a poor place for a long-lived one. For anything that must persist or rotate, prefer a mounted secret file or a managed store, and use the environment only for the handle or the short-lived token. And whatever the source, type the credential as SecretStr the moment it enters your model so it cannot leak through a log line or a traceback.

Key takeaways

The invariant this page enforces: no value leaves os.environ untyped and no required key is allowed to default silently. Type every read, fail fast on the rest, and feed the results into a validated settings model. Environment variables are the twelve-factor baseline because every runtime — container, function, unit, CI job, laptop — agrees on the KEY=value interface, which makes a process that reads its config from the environment portable everywhere. The tax for that portability is that everything arrives as a string, and this page is the discipline for paying it: coerce each value to its real type in a named accessor, fail fast and loudly on a missing required value or a malformed one, and keep secrets out of the environment’s visible surfaces.

The three accessors — get_bool, get_int, get_list — are the manual embodiment of that discipline, and the boolean one closes the single most common environment bug by defining an explicit truthy set instead of trusting Python’s string truthiness. Beyond a handful of fields, these accessors are exactly what a pydantic-settings model gives you declaratively — the same coercion, the same fail-fast behaviour, expressed as declared field types rather than hand-written functions. Whether you write the accessors by hand or reach for the model, the rule is the same: the environment is untyped and unsafe until you reimpose types on the way in, so do it at one boundary, loudly, and let nothing untyped past it.