Environment Variables and Configuration in Python: dotenv, os.environ and Config Files
Configuration management is essential for building secure, portable applications. The core principle: never hardcode credentials or database URLs in source code — use environment variables or external config files.
1. Environment variables with os.environ
import os
# Read an environment variable
db_url = os.environ.get("DATABASE_URL")
if db_url is None:
raise RuntimeError("DATABASE_URL is not set")
# Default value if not present
debug = os.environ.get("DEBUG", "false").lower() == "true"
port = int(os.environ.get("PORT", "8000"))
print(f"DB: {db_url}")
print(f"Debug: {debug}, Port: {port}")
# List all environment variables
for key, value in sorted(os.environ.items()):
print(f"{key}={value}")
# Set a variable (current process only)
os.environ["MY_VAR"] = "my_value"
print(os.environ["MY_VAR"])
2. python-dotenv: load .env files
pip install python-dotenv
.env file (never commit to git — add to .gitignore):
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
SECRET_KEY=super-secret-key-at-least-32-chars
DEBUG=true
PORT=8000
ALLOWED_HOSTS=localhost,127.0.0.1
API_KEY=my-private-api-key
from dotenv import load_dotenv
import os
load_dotenv() # Loads .env into os.environ
db_url = os.environ["DATABASE_URL"]
secret_key = os.environ["SECRET_KEY"]
debug = os.environ.get("DEBUG", "false") == "true"
port = int(os.environ.get("PORT", "8000"))
print(f"Connecting to: {db_url}")
Advanced load_dotenv options
from dotenv import load_dotenv, find_dotenv
from pathlib import Path
# Load a specific .env file
load_dotenv(dotenv_path=Path("/path/to/.env.production"))
# Don't override already-set system variables
load_dotenv(override=False)
# Search .env upward through parent directories
load_dotenv(find_dotenv())
3. Multiple environments
import os
from dotenv import load_dotenv
ENV = os.environ.get("APP_ENV", "development")
# Load base .env first, then environment-specific overrides
load_dotenv(".env")
load_dotenv(f".env.{ENV}", override=True)
print(f"Environment: {ENV}")
print(f"DB: {os.environ.get('DATABASE_URL')}")
File structure:
project/
├── .env # Base values (no secrets)
├── .env.development # Local overrides (in .gitignore)
├── .env.production # Production values (in .gitignore)
├── .env.example # Template without real values (in git)
└── app.py
4. Centralized config class
import os
from dataclasses import dataclass, field
from dotenv import load_dotenv
load_dotenv()
@dataclass
class Config:
database_url: str = field(default_factory=lambda: os.environ["DATABASE_URL"])
db_pool_size: int = field(default_factory=lambda: int(os.environ.get("DB_POOL_SIZE", "5")))
secret_key: str = field(default_factory=lambda: os.environ["SECRET_KEY"])
debug: bool = field(default_factory=lambda: os.environ.get("DEBUG","false")=="true")
port: int = field(default_factory=lambda: int(os.environ.get("PORT","8000")))
allowed_hosts: list = field(default_factory=lambda: os.environ.get("ALLOWED_HOSTS","localhost").split(","))
def __post_init__(self):
if not self.secret_key:
raise ValueError("SECRET_KEY is required")
if len(self.secret_key) < 32:
raise ValueError("SECRET_KEY must be at least 32 characters")
config = Config()
print(f"Port: {config.port}, Debug: {config.debug}")
5. TOML configuration (Python 3.11+)
# config.toml
[app]
name = "MyApp"
debug = false
port = 8000
[database]
host = "localhost"
port = 5432
name = "mydb"
[logging]
level = "INFO"
file = "app.log"
import tomllib # Python 3.11+; use 'tomli' for older versions
with open("config.toml", "rb") as f:
config = tomllib.load(f)
print(config["app"]["name"])
print(config["database"]["host"])
6. INI configuration (configparser)
# config.ini
[DEFAULT]
debug = false
port = 8000
[development]
debug = true
database_url = sqlite:///dev.db
log_level = DEBUG
[production]
database_url = postgresql://localhost/mydb
log_level = WARNING
import configparser, os
config = configparser.ConfigParser()
config.read("config.ini")
env = os.environ.get("APP_ENV", "development")
section = config[env]
debug = section.getboolean("debug")
port = section.getint("port")
db_url = section["database_url"]
log_level = section["log_level"]
print(f"Debug: {debug}, Port: {port}")
7. Validated config with pydantic-settings
pip install pydantic pydantic-settings
from pydantic_settings import BaseSettings
from pydantic import AnyUrl, validator
class Settings(BaseSettings):
database_url: AnyUrl
secret_key: str
debug: bool = False
port: int = 8000
allowed_hosts: list[str] = ["localhost"]
@validator("secret_key")
def min_length(cls, v):
if len(v) < 32:
raise ValueError("SECRET_KEY must be at least 32 chars")
return v
class Config:
env_file = ".env"
settings = Settings()
print(settings.database_url)
8. Masking secrets in logs
import os, re
class SecretStr:
def __init__(self, value: str):
self._value = value
def get_secret_value(self) -> str:
return self._value
def __repr__(self) -> str:
return "**********"
def __str__(self) -> str:
return "**********"
api_key = SecretStr(os.environ.get("API_KEY", ""))
print(f"API key: {api_key}") # API key: **********
print(api_key.get_secret_value()) # real-key
def mask_url(url: str) -> str:
return re.sub(r"://[^:]+:[^@]+@", "://***:***@", url)
db = "postgresql://user:password123@localhost/mydb"
print(mask_url(db)) # postgresql://***:***@localhost/mydb
9. Fail-fast validation on startup
import os
REQUIRED_VARS = ["DATABASE_URL", "SECRET_KEY", "API_KEY"]
def validate_config():
missing = [v for v in REQUIRED_VARS if not os.environ.get(v)]
if missing:
raise EnvironmentError(
f"Missing required environment variables: {', '.join(missing)}\n"
f"Copy .env.example to .env and fill in the values."
)
validate_config() # Call this at app startup
Summary
| Tool | Primary use |
|---|---|
os.environ |
Read system variables |
python-dotenv |
Load .env files locally |
configparser |
.ini files with sections |
tomllib |
.toml files (Py 3.11+) |
pydantic-settings |
Typed, validated configuration |
Related conversions
Frequent conversions across the catalogue: