os.environ is a mapping of the process’s environment variables. os.environ['KEY'] raises KeyError when the variable is absent; os.environ.get('KEY', default) returns a default instead. Every value is a string — there are no integers or booleans, and bool('False') is True, which is a bug people ship regularly.
Environment variables are the standard way to configure an application without putting the configuration in the code. Python’s interface to them is a dictionary-like object, and it behaves like a dictionary in most respects and unlike one in a few that matter.
Table of contents
- Reading values
- Everything is a string
- Writing, and why child processes matter
- Local development with .env files
- Reading the environment safely in logs and errors
- How this fits the rest of the stack
- FAQ
Reading values
import os
os.environ['DATABASE_URL'] # KeyError if unset
os.environ.get('DATABASE_URL') # None if unset
os.environ.get('PORT', '8000') # a default
os.getenv('PORT', '8000') # identical to .get()
'DATABASE_URL' in os.environ # presence check
list(os.environ.keys()) # everything
os.getenv and os.environ.get are the same function for practical purposes. Use whichever reads better.
The choice between bracket access and .get() is a design decision rather than a style one:
- Required configuration — use brackets. A missing
DATABASE_URLshould stop the process at startup with a clear error, not produceNonethat fails a thousand lines later inside a connection call. - Optional configuration — use
.get()with a sensible default.
Better still, fail loudly with an explanation:
def required(name):
try:
return os.environ[name]
except KeyError:
raise RuntimeError(f'{name} is required but not set') from None
DATABASE_URL = required('DATABASE_URL')
Validate every required variable at import time, not on first use. A service that starts successfully and then fails on the first request that touches a missing setting is much harder to diagnose than one that refuses to start.
Everything is a string
This is where the real bugs are.
os.environ['DEBUG'] = 'False'
bool(os.environ['DEBUG']) # True -- any non-empty string is truthy
os.environ['PORT'] = '8000'
os.environ['PORT'] + 1 # TypeError
bool('False') being True is the classic one. It ships to production as debug mode left on, or a feature flag that cannot be turned off.
Convert explicitly, and be careful about what counts as true:
def env_bool(name, default=False):
value = os.environ.get(name)
if value is None:
return default
return value.strip().lower() in {'1', 'true', 'yes', 'on'}
def env_int(name, default=None):
value = os.environ.get(name)
if value is None:
if default is None:
raise RuntimeError(f'{name} is required')
return default
try:
return int(value)
except ValueError:
raise RuntimeError(f'{name} must be an integer, got {value!r}') from None
For anything beyond a handful of settings, use a library rather than hand-rolling this. Pydantic Settings gives you typed configuration with validation at startup:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
port: int = 8000
debug: bool = False
settings = Settings() # raises with a clear message if anything is wrong
That gives you the correct behaviour by default — required fields enforced, types coerced properly, and a useful error naming the field.
Writing, and why child processes matter
os.environ['MY_VAR'] = 'value'
del os.environ['MY_VAR']
os.environ.setdefault('LOG_LEVEL', 'INFO')
Two rules that are not obvious:
Changes affect this process and its future children only. You cannot modify the environment of the shell that started Python. This is why a script cannot set a variable for the terminal it was run from — the child cannot write to the parent’s environment. That is an operating system property, not a Python limitation.
import subprocess
os.environ['CHILD_VAR'] = 'visible'
subprocess.run(['printenv', 'CHILD_VAR']) # visible
# or pass an explicit environment
subprocess.run(['printenv'], env={'ONLY': 'this'})
# or extend rather than replace
subprocess.run(['printenv'], env={**os.environ, 'EXTRA': 'value'})
Passing env= replaces the environment entirely. A child launched with env={'ONLY': 'this'} has no PATH, which produces a confusing FileNotFoundError on a binary that plainly exists. Extend with {**os.environ, ...} unless you specifically want isolation.
Values must be strings. Assigning an integer raises TypeError. Convert on the way in.
Local development with .env files
Environment variables are awkward to set by hand during development, which is what .env files solve:
# .env
DATABASE_URL=postgresql://localhost/myapp_dev
DEBUG=true
SECRET_KEY=dev-key-not-for-production
from dotenv import load_dotenv
load_dotenv() # populates os.environ from .env
By default load_dotenv does not overwrite variables that are already set, which is the right behaviour — real environment variables in production should win over a file that happens to be present.
The rules that keep this safe:
.envgoes in.gitignore. Always, before the first commit. A secret committed once is in the history permanently.- Commit a
.env.examplewith the keys and placeholder values, so a new developer knows what to set. - Do not use
.envfiles in production. The platform should supply the environment. A file on disk is one more thing to deploy, secure and rotate. - Add
.envto.dockerignoreas well, or it gets baked into an image layer.
Check nothing has leaked before pushing:
git log --all --full-history -- .env
git ls-files | grep -E '^\.env$'
If either returns anything, the secret is in history and rotating it is the only real fix — removing the file from the current commit does not remove it from the repository.
Reading the environment safely in logs and errors
A habit worth building early: never log the environment wholesale.
print(os.environ) # dumps every secret you have
print(dict(os.environ)) # same problem
This finds its way into debug output, exception handlers and error-reporting integrations, and the result is credentials sitting in a logging system that many more people can read than can read your production configuration.
If you need to log configuration, allowlist it and redact the rest:
SENSITIVE = ('SECRET', 'TOKEN', 'PASSWORD', 'KEY', 'CREDENTIAL', 'DSN')
def safe_environ():
return {
k: ('<redacted>' if any(s in k.upper() for s in SENSITIVE) else v)
for k, v in os.environ.items()
}
Substring matching is deliberately broad here. A false positive redacts something harmless; a false negative publishes a key.
The same applies to exception tracebacks — many error reporters capture local variables, and a DATABASE_URL containing a password in a stack frame ends up in the report. Most reporters support a denylist for exactly this reason, and configuring it is worth the ten minutes.
How this fits the rest of the stack
Environment variables are the standard answer to configuration because they keep secrets out of the code and let the same artefact run in several places. That only holds if the environment is actually supplied by something other than a file you committed.
RunxBuild sets environment variables per service in the dashboard, so the running process receives its configuration from the platform rather than from a .env in the image — and the deploy that reads them is built from your repository with logs you can check when a variable is missing. If you want to see what a Python service costs alongside a managed Postgres or MySQL, the RunxBuild hosting calculator itemises them.
Useful related references:
- Django Environment Variables: django-environ, os.environ, and 12-Factor
- Python Environment Variables: os.environ, .env Files, and pydantic
- Python services on RunxBuild
FAQ
What is the difference between os.environ and os.getenv?
os.environ['KEY'] raises KeyError when the variable is missing; os.getenv('KEY', default) and os.environ.get('KEY', default) return the default instead. Use brackets for required configuration so the process fails at startup, and .get() for genuinely optional settings.
Why is my boolean environment variable always True?
Because every environment value is a string and any non-empty string is truthy, so bool('False') is True. Convert explicitly by checking membership in a set such as {'1', 'true', 'yes', 'on'} after lowercasing, or use a settings library that handles the coercion.
Why does setting os.environ not affect my shell?
A child process cannot modify its parent’s environment — that is an operating system rule, not a Python one. Changes to os.environ apply to the current process and any children it starts afterwards, and nothing else.
Should I use .env files in production?
No. Use the platform’s environment configuration instead. A .env file in production is another artefact to deploy, secure and rotate, and it is easily baked into a container image by accident. Keep .env for local development, in .gitignore and .dockerignore.
How do I pass environment variables to a subprocess?
They are inherited automatically. To add to them, pass env={**os.environ, 'EXTRA': 'value'} — passing env= alone replaces the entire environment, which removes PATH and produces a confusing FileNotFoundError on binaries that exist.