os.environ vs python-dotenv: best practices

os.environ and python-dotenv solve two different halves of the same problem, and confusing them is how a local .env ends up overwriting a production secret. This page draws the line clearly between the two tools. It extends Environment Variables & os.environ.

The clean mental model is a division of labour. os.environ is the source your application reads — the single, authoritative place every configuration value comes from, populated in production by the orchestrator and in development by your shell. python-dotenv is a development convenience whose only job is to seed missing values into that environment from a local .env file, so a developer does not have to export a dozen variables by hand. The application never reads the .env file directly; it reads os.environ, and dotenv has quietly filled the gaps. Keep that separation and your code has exactly one code path — read os.environ — that is identical in development and production, with dotenv acting only as an invisible local helper that does nothing at all in production because there is no .env there to load, and needs none.

The two anti-patterns below are the ways teams blur that line: treating the .env file as the source of truth (so the app breaks in production where the file is absent), and using override=True (so a stale local file outranks the real, injected environment). Both invert the relationship — they make the .env authoritative over os.environ, which is exactly backwards from the precedence that keeps development and production consistent.

To see why the direction matters, picture what each environment actually looks like. On a laptop, there is no orchestrator injecting variables, so the .env file is where a developer’s configuration lives; dotenv reads it into os.environ and the app runs. In production, the orchestrator injects every variable directly into the process environment before the app starts, and there is no .env file at all — dotenv, if it runs, finds nothing and does nothing. The app’s code is identical in both: it reads os.environ. The .env file is simply the mechanism that makes the laptop look like production to the app, by pre-populating the environment the app expects. Once you see it that way, it is obvious that the file must never be allowed to outrank the environment — the file exists to simulate the environment locally, not to override it. Getting the direction backwards — letting the simulation win over the real thing — is exactly the class of bug where a developer’s stale local value silently overrides what production injected, and it works fine everywhere except the one place it matters.

Problem 1: treating .env as the source of truth

# ANTI-PATTERN: production has no .env, so this raises or returns junk
from dotenv import load_dotenv
load_dotenv()
db = os.environ["DATABASE_URL"]   # only works if .env exists — it shouldn't in prod

python-dotenv is a local convenience. In production the values come from the orchestrator, and there is no .env file at all. If your code depends on load_dotenv() having found a file — if it would break when the file is absent — then you have made the .env a required part of your configuration, and it will fail in exactly the environment where it matters most. The correct posture is that load_dotenv() doing nothing (because there is no file) is a completely normal, expected outcome: in production the values are already in os.environ, dotenv finds no file, and the app reads its config exactly as it would locally. The .env file is scaffolding for developer convenience, not a link in the production configuration chain.

A useful test for whether you have this right: if you deleted python-dotenv from your production image entirely, would the app still start? It should, because production’s values come from the injected environment, not the file. If removing dotenv would break production, then you have accidentally made the file a required part of your configuration — and the fix is to ensure production injects its values directly and dotenv is only meaningful in development. In practice the “no file, no action” behaviour makes this automatic: production ships no .env, so the hydration call finds nothing and does nothing, and the app reads the injected environment exactly as designed, with no environment branching required.

A .env exists in development but not in production Development has a .env file that dotenv seeds into os.environ, while production has no .env and the orchestrator injects values directly; the app reads os.environ in both. development .env file os.environ production no .envorchestrator injects os.environ app reads os.environ in both
The .env exists only in development; the app reads os.environ everywhere, so dotenv finding no file in production is normal.

Problem 2: override flips the precedence

# ANTI-PATTERN: local file wins over the injected secret
load_dotenv(override=True)        # stale .env beats the real environment

With override=True, the developer’s file outranks the platform — the exact inversion of what production parity requires. The default load_dotenv() uses override=False, which is correct: it fills only keys that are not already set, so anything the real environment already provides always wins over the file. Flipping to override=True makes the .env file authoritative over os.environ, and now a stale value in a developer’s local file — or worse, a .env that somehow made it into a container — silently replaces the injected production value. The bug is insidious because it works fine locally (where the .env is the intended source) and only misbehaves where a real environment value should have won. The rule is simple: never use override=True for configuration; the environment must always be able to override the file, never the other way around.

override=False preserves precedence; override=True inverts it With override=False the injected environment value wins over the .env file; with override=True the stale .env file clobbers the injected value, inverting the intended precedence. override=False (correct) .env value injected wins environment overrides the file override=True (wrong) stale .env wins injected lost file clobbers the injected value precedence must always be: environment over file
override=False keeps the environment authoritative; override=True lets a stale file clobber the injected value.

Secure implementation

# config/env_source.py
import os
from pathlib import Path
from dotenv import dotenv_values

def hydrate_from_dotenv() -> None:
    """Fill ONLY missing keys; os.environ stays authoritative."""
    path = Path(".env")
    if not path.exists():
        return
    for key, value in dotenv_values(path).items():
        if value and key not in os.environ:   # override=False, explicitly
            os.environ[key] = value

# os.environ is the single source the app reads; dotenv only seeds gaps locally.
hydrate_from_dotenv()
DATABASE_URL = os.environ["DATABASE_URL"]      # same line works in dev and prod

The application always reads os.environ. python-dotenv merely seeds missing keys during local development, and only when they are not already set.

The implementation makes the “seed gaps only” contract explicit. It uses dotenv_values() — which parses the file into a dict without mutating anything — rather than load_dotenv(), and then sets os.environ[key] = value only when key not in os.environ. That not in os.environ guard is override=False written by hand, and doing it explicitly makes the precedence impossible to get wrong. If the .env file does not exist, the function returns immediately and does nothing, so production (which has no .env) is unaffected. The rest of the application then reads os.environ["DATABASE_URL"] — a single line that behaves identically in development (where dotenv seeded it) and production (where the orchestrator injected it), which is exactly the parity you want.

There is a reason to prefer this explicit hydration over the one-liner load_dotenv(), even though load_dotenv() already defaults to override=False. The explicit version makes the precedence visible in your own code: anyone reading if value and key not in os.environ can see immediately that injected values win. It also gives you a natural place to add behaviour — logging which keys were seeded (never their values), validating that required keys are present after hydration, or supporting multiple .env files. And because it uses dotenv_values(), which returns a plain dict without side effects, it is easy to test: you can call the parse and assert on its output without touching or having to restore the global environment. For a small project load_dotenv() is perfectly fine; the explicit form is worth it when you want the precedence to be obvious and the hydration to be testable.

dotenv seeds only missing keys; the app reads os.environ The hydrate function reads .env values and sets only keys not already in os.environ, so injected values survive; the application then reads os.environ exclusively. .env values dotenv_values() seed if not set key not in os.environ os.environ authoritative app reads it injected values are never overwritten; dotenv only fills gaps
dotenv fills only the gaps in os.environ, which stays authoritative and is the single thing the app reads.

Gotchas & version-specific behaviour

  • load_dotenv() mutates os.environ; dotenv_values() does not — prefer the latter for control.
  • python-dotenv is a dev/test dependency; it should not be required for the app to boot in production.
  • Env-var values are strings; type them through a typed accessor or a pydantic model.
  • case_sensitive differs by platform — be explicit in your settings model.

The load_dotenv()-versus-dotenv_values() distinction is the one to internalise. load_dotenv() mutates the global os.environ as a side effect, which is convenient but hides what happened; dotenv_values() just returns a dict and lets you decide what to do with it, which is exactly what you want when the decision is “seed only the missing keys, and nothing more”. Preferring dotenv_values() makes the precedence logic visible in your own code rather than buried in a library default. The dependency-scope gotcha matters for production hygiene: python-dotenv is a development and test convenience, and your application should not require it to boot — if it is missing in production, the app should still start (reading the injected environment), because production has no .env for dotenv to load anyway. Treat it as a dev dependency, not a runtime one.

The values-are-strings gotcha connects this page to the rest of the configuration story: whether dotenv seeded a value or the orchestrator injected it, what lands in os.environ is always a string, and it still needs typing before use. So the correct pipeline is: dotenv (locally) or the orchestrator (in production) populates os.environ; then a typed accessor or a pydantic-settings model reads and coerces those strings into the bool, int, and list values your code needs. dotenv is only responsible for the sourcing half; the typing half is a separate concern that happens after, regardless of where the value came from. Keeping those two responsibilities distinct is what keeps each simple — dotenv seeds, the model types, and neither has to know about the other. It also means you can change one without touching the other: swap dotenv for a different seeding mechanism, or replace hand-rolled accessors with a full pydantic model, and the other half of the pipeline is completely unaffected.

The case-sensitivity gotcha is worth a note for anyone moving to a settings model. Environment-variable name matching can be case-sensitive or not depending on the platform and your model’s case_sensitive setting, and .env files and injected variables can differ subtly in casing. The safe default in a pydantic-settings model is case_sensitive=False, which matches the upper-snake environment convention onto lower-snake Python fields without manual aliases; be explicit about it in the model rather than relying on a platform default that might quietly differ between your laptop and production.

Four os.environ-versus-dotenv gotchas Prefer dotenv_values over load_dotenv for control, treat python-dotenv as a dev dependency, type env values through an accessor or model, and be explicit about case_sensitivity. dotenv_valuesDev dependency Type the valuescase_sensitive returns a dict; you control precedence the app must boot in prod without it through an accessor or a pydantic model differs by platform — be explicit
Prefer dotenv_values for control, keep dotenv a dev dependency, type the values, and be explicit about casing.

Production parity checklist

  • The app reads os.environ only; .env seeds gaps locally with override=False.
  • .env is gitignored; .env.example documents the keys.
  • Production injects values via the orchestrator — no .env shipped.
  • Required keys validated at startup; secrets wrapped in SecretStr.

The first item is the one that makes everything else consistent: the application reads os.environ and nothing else. Not the .env file directly, not dotenv_values() at the point of use — just os.environ, seeded at startup by dotenv in development and by the orchestrator in production. Gitignoring .env (and committing a placeholder .env.example that documents the keys) keeps secrets out of version control while giving new developers a template. Shipping no .env to production and injecting values via the orchestrator is what makes dotenv’s absence there a non-event. Together these turn os.environ versus python-dotenv from a source of confusion into a clean division: one authoritative source the app reads, one dev-only helper that fills its gaps.

os.environ-versus-dotenv production-parity checklist The app reads os.environ only with .env seeding gaps via override=False, .env is gitignored with an example committed, production injects values with no .env shipped, and required keys are validated with secrets wrapped in SecretStr. The app reads os.environ only; .env seeds gaps with override=False .env is gitignored; .env.example documents the keys Production injects values via the orchestrator — no .env shipped Required keys validated at startup; secrets wrapped in SecretStr
One authoritative source the app reads, one dev-only helper that fills its gaps.

Key takeaways

Read os.environ; use python-dotenv only to seed missing keys locally, never to override. That keeps one code path identical across environments. The two tools have distinct jobs: os.environ is the single authoritative source your application reads, populated by the orchestrator in production and by dotenv in development; python-dotenv is a development convenience whose only role is to fill missing keys from a local .env file. Blur that line — treat the .env as the source of truth, or use override=True so it outranks the environment — and you break the parity that makes local testing predict production behaviour.

The concrete habits that keep the line sharp: seed with override=False (or the explicit not in os.environ guard), read os.environ everywhere in the app, gitignore .env and commit a .env.example, inject values via the orchestrator in production, and treat python-dotenv as a dev-only dependency. Do that, and dotenv finding no .env in production is a completely normal outcome, the same read works in every environment, and a stale local file can never clobber an injected secret or a production-tuned value. For typed reads of those values, see Reading Typed Env Vars.