Managing Config with python-dotenv
python-dotenv loads environment variables from a .env file into your Python application. This keeps sensitive configuration—like API keys, database passwords, and secrets—out of your source code.
Why use python-dotenv?
In Python, environment variables are the standard way to handle configuration that changes between environments (development, staging, production). Instead of hardcoding values or using separate config files for each environment, you use a single .env file that you keep out of version control.
The workflow is simple: developers create a .env file locally with their own values, and the application loads those values at runtime. This way, secrets never enter the repository.
Installation
Install python-dotenv with pip:
pip install python-dotenv
Getting Started
Creating a .env file
Create a file named .env in your project root:
# .env
DATABASE_URL=postgresql://user:password@localhost/mydb
API_KEY=your-secret-api-key-here
DEBUG=True
Important: Add .env to .gitignore
Your .env file contains secrets. Never commit it to version control:
# .gitignore
.env
Loading variables in Python
Import and call load_dotenv() at the start of your application:
from dotenv import load_dotenv
import os
# Load environment variables from .env file
load_dotenv()
# Now you can access them
database_url = os.getenv("DATABASE_URL")
api_key = os.getenv("API_KEY")
debug = os.getenv("DEBUG")
print(f"Debug mode: {debug}")
If .env doesn’t exist, load_dotenv() does nothing silently—no error is raised.
Finding the .env File
By default, load_dotenv() looks for .env in the current working directory. You can specify a custom path:
from dotenv import load_dotenv
# Load from a specific location
load_dotenv("/path/to/your/.env")
# Or use a relative path from this file
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
Using with os.environ
If you prefer direct access via environ:
from dotenv import load_dotenv
from os import environ
load_dotenv()
# Access like regular environment variables
api_key = environ.get("API_KEY")
This works because load_dotenv() updates the process environment.
Optional Values with Default
Use .get() with a default for optional variables:
from dotenv import load_dotenv
import os
load_dotenv()
# Default to False if DEBUG is not set
debug = os.getenv("DEBUG", "False") == "True"
# Useful for feature flags
feature_enabled = os.getenv("NEW_FEATURE", "False") == "True"
Loading Multiple Files
For different environments, load different files:
from dotenv import load_dotenv
import os
# Load default .env first
load_dotenv()
# Then override with environment-specific file
env = os.getenv("ENV", "development")
load_dotenv(f".env.{env}") # e.g., .env.production
The later call overwrites earlier values, so production settings take precedence.
Working with Paths
For larger projects, keep your .env file in a known location:
from pathlib import Path
from dotenv import load_dotenv
# Project root is two levels up from this file
project_root = Path(__file__).parent.parent
env_path = project_root / ".env"
load_dotenv(env_path)
Real-World Example
A typical Flask application setup:
# app.py
from flask import Flask
from dotenv import load_dotenv
import os
# Load config first thing
load_dotenv()
app = Flask(__name__)
# Configure from environment variables
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "dev-secret-key")
app.config["DATABASE_URL"] = os.getenv("DATABASE_URL")
# Use in your routes
@app.route("/api/key")
def get_api_key():
api_key = os.getenv("API_KEY")
if not api_key:
return {"error": "API key not configured"}, 500
return {"key": api_key}
The corresponding .env:
# .env
SECRET_KEY=your-production-secret-key
DATABASE_URL=postgresql://prod-server/mydb
API_KEY=sk-prod-1234567890
Django Example
Django applications often use python-dotenv in their settings module:
# myproject/settings.py
from pathlib import Path
from dotenv import load_dotenv
# Load .env in the project root
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
# Now access variables
SECRET_KEY = os.getenv("SECRET_KEY")
DEBUG = os.getenv("DEBUG", "False") == "True"
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv("DB_NAME"),
"USER": os.getenv("DB_USER"),
"PASSWORD": os.getenv("DB_PASSWORD"),
"HOST": os.getenv("DB_HOST", "localhost"),
"PORT": os.getenv("DB_PORT", "5432"),
}
}
Understanding .env File Format
The .env file uses a simple key-value format:
# Comments start with #
KEY=value
ANOTHER_KEY="value with spaces"
QUOTED_VALUE="This is preserved"
# Empty values are valid
EMPTY_VAR=
# Variables can reference other variables (in some shells)
PATH_VAR=$PATH:/custom/path
Quotes and Spaces
Pay attention to whitespace in your .env file:
# No quotes - spaces are trimmed
KEY=value with spaces # Becomes "value with spaces"
# Double quotes - preserves spaces
KEY="value with spaces" # Stays "value with spaces"
# Single quotes - literal, no interpolation
KEY='value with $vars' # Stays exactly as written
The override Parameter
By default, load_dotenv() does not override existing environment variables. This is useful when you want to allow shell-set variables to take precedence:
from dotenv import load_dotenv
# Existing env vars are preserved
load_dotenv() # Won't override if KEY already exists
# Force override everything from .env
load_dotenv(override=True) # Will overwrite any existing value
This is particularly useful in testing scenarios where you want to reset environment state.
Verbose Mode
When debugging why variables aren’t loading as expected, use verbose mode:
from dotenv import load_dotenv
# Prints each loaded variable to stdout
load_dotenv(verbose=True)
This helps identify if your file is being found and parsed correctly.
Using with Docker
In containerized applications, you might combine dotenv with Docker’s built-in environment variable handling:
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
# Install python-dotenv
RUN pip install python-dotenv
# Copy requirements first for better caching
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
# Create a .env file from Docker build args
ARG DATABASE_URL
ARG API_KEY
ENV DATABASE_URL=$DATABASE_URL
ENV API_KEY=$API_KEY
CMD ["python", "app.py"]
Then set variables at runtime:
docker run -e DATABASE_URL="postgresql://..." -e API_KEY="..." myapp
Or use Docker Compose for local development:
# docker-compose.yml
services:
web:
build: .
env_file:
- .env
ports:
- "8000:8000"
Loading in Different Scenarios
With virtual environments
python-dotenv works seamlessly with virtual environments. The variables you load are available to your entire Python process:
# Create and activate venv
python -m venv venv
source venv/bin/activate
# Install packages
pip install python-dotenv flask
# Run your app - dotenv loads automatically
python app.py
With Docker Compose overrides
You can layer multiple env files:
from dotenv import load_dotenv
# Base configuration
load_dotenv()
# Environment-specific override
env = os.getenv("APP_ENV", "development")
load_dotenv(f".env.{env}.local", override=True)
This allows developers to have .env.development.local with their personal overrides without affecting the shared .env.development file.
Configuration Patterns
Centralized Config
For larger applications, consider a config module:
# config.py
from functools import lru_cache
from dotenv import load_dotenv
import os
@lru_cache()
def get_config():
load_dotenv()
class Config:
DATABASE_URL = os.getenv("DATABASE_URL")
API_KEY = os.getenv("API_KEY")
DEBUG = os.getenv("DEBUG", "False") == "True"
return Config()
# Usage
config = get_config()
print(config.DATABASE_URL)
Environment-Based Config
Combine dotenv with class-based config for different environments:
# config.py
from dotenv import load_dotenv
import os
load_dotenv()
class BaseConfig:
"""Base configuration"""
DATABASE_URL = os.getenv("DATABASE_URL")
class DevelopmentConfig(BaseConfig):
"""Development settings"""
DEBUG = True
DATABASE_URL = os.getenv("DEV_DATABASE_URL", BaseConfig.DATABASE_URL)
class ProductionConfig(BaseConfig):
"""Production settings"""
DEBUG = False
def get_config():
env = os.getenv("APP_ENV", "development")
configs = {
"development": DevelopmentConfig,
"production": ProductionConfig,
}
return configs.get(env, DevelopmentConfig)
This pattern gives you the simplicity of dotenv with the organization of class-based configuration.
Best Practices
- Never commit secrets — Add
.envto.gitignore - Use
.env.example— Commit a template file with placeholder values so developers know what variables are needed:# .env.example DATABASE_URL= API_KEY= - Use different files for environments —
.env.development,.env.production - Validate required variables — Check for required keys at startup:
required = ["DATABASE_URL", "API_KEY"] missing = [k for k in required if not os.getenv(k)] if missing: raise ValueError(f"Missing required env vars: {missing}")
See Also
- env-variables — Working with environment variables in Python
- argparse-guide — Building CLIs with Python’s standard library
- python-secrets-module — Generating secure random values