Python reads environment variables through the os module, but real-world projects rarely stop there. Libraries like python-dotenv, pydantic-settings, and django-environ add file loading, type coercion, and validation on top. Every environment variable is a string at the OS level, so converting to the correct Python type is your responsibility. This guide covers the standard library, the most popular third-party tools, framework-specific patterns for Django and Flask, and the mistakes that catch developers off guard.
TL;DR
os.environ['KEY']raisesKeyErrorwhen missing;os.getenv('KEY', default)returnsNoneor your default instead of raising.load_dotenv()does not override variables already present inos.environ— the default isoverride=False. Real shell exports and CI secrets win over your.envunless you passoverride=True.os.environis a cache snapshotted when the interpreter starts. Changes made outside Python (or viaos.putenv()) stay invisible until you callos.reload_environ(), added in Python 3.14 (October 2025).- Every value is a string, so
bool(os.getenv('DEBUG', 'false'))isTrue— any non-empty string is truthy. Reach forpydantic-settingswhen you want typed, validated config instead of hand-rolled casts.
How do os.environ and os.getenv() differ?
The os module provides two ways to read environment variables. os.environ is a dict-like mapping that raises KeyError when a variable is missing. os.getenv() returns None (or a default you specify) instead of raising.
import os
# Raises KeyError if DATABASE_URL is not set
db_url = os.environ['DATABASE_URL']
# Returns None if missing — no exception
db_url = os.getenv('DATABASE_URL')
# Returns a fallback value if missing
db_url = os.getenv('DATABASE_URL', 'sqlite:///local.db')
# You can also use .get() on os.environ directly
db_url = os.environ.get('DATABASE_URL', 'sqlite:///local.db')
# Set a variable for the current process
os.environ['APP_MODE'] = 'debug'Use os.environ['VAR'] when the variable is required and the application should crash immediately if it is missing. Use os.getenv() when a sensible default exists.
os.environ vs os.getenv() vs python-dotenv: which do you use?
These three are not competitors — they sit at different layers. os.environ and os.getenv() are both read accessors from the standard library; python-dotenv is a loader that populates os.environ from a file so the other two have something to read.
| Feature | os.environ['KEY'] | os.getenv('KEY') | python-dotenv |
|---|---|---|---|
| Role | Read (mapping) | Read (function) | Loads a file into os.environ |
| Missing key | Raises KeyError | Returns None / default | n/a (writes values) |
| Default value | .get('KEY', default) | 2nd argument | n/a |
| Type coercion | None (str only) | None (str only) | None (str only) |
| Install | stdlib | stdlib | pip install python-dotenv |
| Reach for it when | Var is required | Var is optional | Loading local dev config |
In production you typically skip python-dotenv entirely — the platform (Docker, Kubernetes, systemd, your CI/CD runner) injects variables directly into the process environment, and you read them with os.getenv(). The .env file is a developer-machine convenience, not a deployment mechanism. For the same split in JavaScript, see the Node.js environment variables guide.
How do you load a .env file with python-dotenv?
The python-dotenv package reads key-value pairs from a .env file and injects them into os.environ. Install it with pip install python-dotenv.
from dotenv import load_dotenv
import os
# Load .env from the current working directory
load_dotenv()
# Or specify an explicit path
load_dotenv('/app/config/.env')
# By default, existing env vars are NOT overridden.
# Pass override=True to replace existing values.
load_dotenv(override=True)
# Now read variables normally
secret_key = os.getenv('SECRET_KEY')The load_dotenv() call must happen before you read any variables. A common pattern is to call it at the top of your entry point module. See the .env guide for an overview, or the .env file syntax reference for the exact rules around quoting, escaping, and comments.
How does Pydantic Settings handle env vars?
pydantic-settings (install with pip install pydantic-settings) combines loading, type coercion, and validation in a single class. It reads from environment variables and .env files automatically.
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
debug: bool = False
max_connections: int = 10
allowed_hosts: list[str] = ['localhost']
model_config = {
'env_file': '.env',
'env_file_encoding': 'utf-8',
}
# Pydantic reads DATABASE_URL, DEBUG, MAX_CONNECTIONS from the environment,
# converts them to the annotated types, and validates them.
settings = Settings()
print(settings.debug) # bool, not str
print(settings.max_connections) # int, not strField names are matched case-insensitively by default (database_url matches DATABASE_URL). Pydantic raises a ValidationError at instantiation if required fields are missing or values cannot be coerced, so you get fail-fast behavior without writing manual checks.
What is the Django pattern with django-environ?
django-environ wraps environment variable reading with type-casting helpers designed for settings.py. Install with pip install django-environ. The Django environment variables guide covers the full settings setup — SECRET_KEY, DEBUG, and production checklists.
# settings.py
import environ
env = environ.Env(
DEBUG=(bool, False),
ALLOWED_HOSTS=(list, ['localhost']),
)
# Read the .env file
environ.Env.read_env('.env')
SECRET_KEY = env('SECRET_KEY')
DEBUG = env('DEBUG') # bool
ALLOWED_HOSTS = env('ALLOWED_HOSTS') # list
DATABASES = {
'default': env.db('DATABASE_URL'), # parses database URLs
}
CACHES = {
'default': env.cache('CACHE_URL'), # parses cache URLs
}
EMAIL_CONFIG = env.email('EMAIL_URL') # parses email URLsThe env.db(), env.cache(), and env.email() helpers parse URL-style connection strings directly into the dict format Django expects, eliminating boilerplate.
How do you configure Flask with environment variables?
Flask does not auto-load .env files. The standard approach is to call load_dotenv() before creating the app, then use app.config.from_prefixed_env() or read values directly.
from dotenv import load_dotenv
load_dotenv()
from flask import Flask
import os
app = Flask(__name__)
# Option 1: read individual vars
app.config['SECRET_KEY'] = os.environ['SECRET_KEY']
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')
# Option 2: load all FLASK_ prefixed vars (strips the prefix)
# FLASK_SECRET_KEY in .env becomes app.config['SECRET_KEY']
app.config.from_prefixed_env()
# Option 3: load from a config object
class Config:
SECRET_KEY = os.environ['SECRET_KEY']
SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL', 'sqlite:///app.db')
DEBUG = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
app.config.from_object(Config)Do virtual environments affect environment variables?
Python virtual environments (venv, virtualenv) do not isolate environment variables. A venv only changes PATH and VIRTUAL_ENV so that the correct Python interpreter and packages are used. All other environment variables are inherited from the parent shell — including PYTHONPATH, which keeps prepending its directories to sys.path inside the venv and can shadow the packages you just installed.
# These are NOT set by activating a venv — they come from the shell
os.getenv('DATABASE_URL') # inherited from parent process
os.getenv('VIRTUAL_ENV') # set by the activate script
# To set env vars per-project, use a .env file with python-dotenv
# or export them in the activate script:
# echo 'export DATABASE_URL=postgres://...' >> .venv/bin/activateWhy doesn't os.environ see a variable I just exported?
The CPython docs describe os.environ as a mapping “captured the first time the os module is imported, typically during Python startup as part of processing site.py.” It is a one-time snapshot. If another process, a C extension, or a call to os.putenv() changes the underlying OS environment after that point, os.environ and os.getenv() keep returning the stale cached value.
Before Python 3.14 there was no built-in way to refresh that cache — you had to restart the process. Python 3.14 (released October 7, 2025) added os.reload_environ(), which re-reads the live environment into os.environ. As of June 2026, Python 3.14 is the current stable release and 3.13 is in maintenance, so most production runtimes do not have this function yet.
import os
# A subprocess or C extension mutated the real environment after startup.
# os.environ still holds the snapshot from interpreter start:
os.getenv('FEATURE_FLAG') # stale — may be None even though it's now set
# Python 3.14+ only: re-read the live OS environment into os.environ
os.reload_environ()
os.getenv('FEATURE_FLAG') # now reflects the current process environment
# Gotcha: assigning through os.environ DOES propagate (it calls putenv);
# calling os.putenv() directly does NOT update os.environ.
os.putenv('X', '1') # os.environ still has no 'X'
os.environ['X'] = '1' # this both sets the OS var and updates the cacheos.reload_environ() is not thread-safe. The docs warn that reading from os.environ or calling os.getenv() in another thread while it reloads may return an empty result. Reload during startup, not mid-request.How do you handle type conversion?
Environment variables are always strings. Forgetting this leads to subtle bugs, especially with booleans. The string "false" is truthy in Python.
import os
# BUG: bool('false') is True — any non-empty string is truthy
debug = bool(os.getenv('DEBUG', 'false')) # True!
# Correct: compare the string value
debug = os.getenv('DEBUG', 'false').lower() in ('true', '1', 'yes')
# Integers
max_conn = int(os.getenv('MAX_CONNECTIONS', '10'))
# Floats
timeout = float(os.getenv('TIMEOUT_SECONDS', '30.0'))
# Lists (common convention: comma-separated)
hosts = os.getenv('ALLOWED_HOSTS', 'localhost').split(',')
# Use Pydantic Settings to avoid manual conversion entirelybool(os.getenv('DEBUG')) returns True for any non-empty string, including "false", "0", and "no". Always compare against explicit string values.How do you set default values and require variables?
Choose deliberately between optional variables with defaults and required variables that must be set.
import os
import sys
# Optional with default
log_level = os.getenv('LOG_LEVEL', 'INFO')
# Required — crash immediately with a clear message
database_url = os.environ.get('DATABASE_URL')
if not database_url:
sys.exit('DATABASE_URL environment variable is required')
# Required — one-liner using os.environ (raises KeyError)
secret_key = os.environ['SECRET_KEY']
# With Pydantic: fields without defaults are required
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
secret_key: str # required — ValidationError if missing
debug: bool = False # optional with default
log_level: str = 'INFO' # optional with defaultHow do you test with environment variables?
Tests should never depend on the developer's actual environment. Use monkeypatch in pytest or unittest.mock.patch.dict to set, override, or remove variables within a test scope.
# pytest — monkeypatch (recommended)
def test_database_config(monkeypatch):
monkeypatch.setenv('DATABASE_URL', 'sqlite:///test.db')
monkeypatch.setenv('DEBUG', 'true')
from myapp.config import get_settings
settings = get_settings()
assert settings.debug is True
def test_missing_var(monkeypatch):
monkeypatch.delenv('SECRET_KEY', raising=False)
# Test that your app raises on missing required vars
# unittest — mock.patch.dict
from unittest.mock import patch
@patch.dict('os.environ', {'API_KEY': 'test-key-123'})
def test_api_client():
import os
assert os.environ['API_KEY'] == 'test-key-123'
@patch.dict('os.environ', {}, clear=True)
def test_empty_environment():
import os
assert os.getenv('DATABASE_URL') is NoneBoth approaches restore the original environment after the test exits, even if the test fails.
What are the most common mistakes?
Forgetting to call load_dotenv()
Installing python-dotenv does nothing by itself. You must call load_dotenv() before reading variables. Place it at the very top of your entry point, before any imports that use env vars.
Wrong .env file path
load_dotenv() searches from the current working directory, which may differ depending on how you launch your app. Use load_dotenv(Path(__file__).resolve().parent / '.env') for a path relative to the source file. For a deeper walkthrough of path-related failures, see dotenv not loading? debugging guide.
Treating env vars as non-strings
os.getenv('PORT') returns "8000", not 8000. Passing it directly to a function expecting an int will fail or silently behave incorrectly.
Committing .env to version control
Add .env to .gitignore immediately. Commit a .env.example with placeholder values instead. If secrets were already committed, rotate them — deleting the file from the repo does not remove it from git history. See environment variable security for the full leak-and-rotate playbook.
Reading env vars at import time
Module-level code like DB_URL = os.getenv('DATABASE_URL') runs when the module is first imported. If load_dotenv() has not been called yet, the value will be None. Use functions or lazy patterns to defer reading until after initialization.
References
- Python docs —
os.environ - Python docs —
os.getenv() - Python 3.14 docs —
os.reload_environ() - python-dotenv — official README
- Pydantic Settings — official documentation
- django-environ — official documentation
- Flask — configuring from environment variables
Check your .env files for syntax errors with the env validator, or read the .env guide for full dotenv syntax and cross-language usage.