Skip to content

Environment Variables and .env Files: What Wins When They Conflict?

When building a Python application, configuration values tend to live in one of three places: real OS environment variables, a .env file, or a hard-coded default in the code itself. These three can conflict, and when a value doesn’t seem to be “taking effect,” the cause is usually a misunderstanding of which one wins. This article walks through the resolution order between the three, and why it’s designed the way it is.

What a .env file actually is

Note: a .env file is a plain text file listing KEY=value pairs, one per line. It’s used to keep environment-specific or sensitive values — API keys, database connection strings — out of the source code itself. It’s almost always excluded from version control via .gitignore.

In Python, the go-to library is python-dotenv. Calling load_dotenv() reads the .env file and populates os.environ with its contents. From that point on, calling code just does os.environ.get('API_KEY') without needing to know or care whether the value came from a real OS-level environment variable or from the .env file.

Three places a value can come from, and their priority

In general, a configuration value can exist at up to three levels:

  1. OS environment variables — values exported in the shell (export API_KEY=...), or injected by CI systems and container runtimes at startup
  2. .env file — values checked into the repository (or kept alongside it) that are loaded only in a developer’s local environment
  3. Hard-coded defaults in the code — the last resort, as in os.environ.get('API_KEY', 'default-value')

Most dotenv-style libraries, python-dotenv included, default to a specific rule here: a value that’s already set as a real OS environment variable is not overwritten by the .env file. Unless you explicitly pass override=True, load_dotenv() respects whatever is already present in os.environ and simply skips any matching key found in the .env file.

That gives a priority order of: OS environment variable > .env file > hard-coded default.

Why this ordering makes sense

At first glance it might seem more intuitive for the value loaded last (the .env file) to win. But there’s a good reason OS environment variables take precedence instead.

The classic case is CI pipelines and containerized deployments. In those environments, secrets management systems — GitHub Actions secrets, a docker run -e flag, and similar mechanisms — inject production-relevant or CI-specific values directly as OS environment variables. If a .env file checked into the repo (typically containing dummy values or a developer’s personal local settings) were allowed to silently overwrite those injected values, tests and deployments would end up running against the wrong configuration without any obvious error.

The underlying principle — a value that has already been explicitly and deliberately set should not be silently overwritten by a file loaded later — is a common pattern in configuration resolution design generally. Ordering things so that “the more intentionally a value was set, the higher its priority” lets both cases work correctly at once: a local developer’s .env file is used whenever no OS environment variable overrides it, while CI and production environments reliably use whatever the infrastructure layer injected, regardless of what happens to be sitting in the .env file.

A similar pattern in our own codebase: explicit setting > auto-detection > hard-coded fallback

This application doesn’t use .env files, but a structurally similar multi-tier fallback shows up in core/log_i18n.py‘s automatic UI-language detection (get_ui_language()):

# Resolution order (first valid value wins):
#   1. Environment variables LC_ALL / LC_MESSAGES / LANG (POSIX standard)
#   2. Windows API: GetUserDefaultUILanguage
#   3. locale.getlocale() / locale.getdefaultlocale() (legacy fallback)
#   4. If nothing resolves -> 'en' (defaults toward international users)

On top of that, the caller of this function checks for an explicit language setting in settings.json first, and skips the automatic detection entirely if one is present.

Laid out as a priority chain, that’s: an explicit user choice (settings.json) > information the OS/shell already exposes (LANG and friends) > a guess from the standard library (the locale module) > a fixed fallback ('en') when nothing else resolves. It’s the same underlying idea as the .env priority rule, applied to a different problem: a deliberately configured value should always outrank an automatically detected one, and there should always be a safe, hard-coded value at the very bottom so the system never has no answer at all.

Takeaway

The typical resolution order for configuration values — OS environment variable, then .env file, then hard-coded default — exists specifically to stop a developer’s local .env file from accidentally overriding values that CI or container infrastructure deliberately injected. That same idea, “prefer the more intentional value and always keep a safe fallback underneath,” generalizes well beyond .env files to configuration resolution in general.