Skip to main content

Loading Secrets from .env Files: The python-dotenv

The python-dotenv library loads key-value pairs from a .env file into your application's environment variables, making local development easier while keeping credentials out of version control. A .env file is a simple text file in your project root with lines like DATABASE_PASSWORD=mysecret, which load_dotenv() reads and populates into os.environ at startup. This pattern is ubiquitous in Python web frameworks (Django, Flask) and modern cloud-native applications, bridging the gap between local development—where you want readable configuration files—and production—where secrets come from managed secret services.

The risk: developers mistakenly commit .env files to Git. This article covers the python-dotenv API, safe .gitignore patterns, and why .env is a development-only tool—never for production secrets. In production, use environment variables injected by your container orchestrator, CI/CD platform, or a secret manager.

What Is a .env File and Why Use It?

A .env file is a plaintext configuration file stored alongside your source code. It contains environment variables in the format KEY=value, one per line. Comments start with #. The python-dotenv package reads this file and populates os.environ, so your code can access values with os.getenv("KEY") exactly as if the variables were set in the shell.

Without .dotenv, you would need to remember to set many environment variables before running your app each time. With it, you can commit a template .env.example file to version control (with dummy values), and developers copy it to .env and fill in their local credentials. This is the recommended pattern for team projects: .env is in .gitignore, .env.example is committed.

Installing and Using python-dotenv

Install it with pip:

pip install python-dotenv

Create a .env file in your project root:

DATABASE_PASSWORD=local_dev_password
DATABASE_HOST=localhost
API_KEY=test_api_key_12345
DEBUG=true

In your Python code, call load_dotenv() at the very start of your application (before any imports that access environment variables):

from dotenv import load_dotenv
import os

# Load .env file into os.environ
load_dotenv()

# Now read variables as usual
db_password = os.getenv("DATABASE_PASSWORD")
api_key = os.getenv("API_KEY")

print(f"Connected to {os.getenv('DATABASE_HOST')}")

load_dotenv() searches for a .env file in the current directory and parent directories (up to the project root). If you want to specify an explicit path:

from dotenv import load_dotenv
import os

# Load a specific .env file
load_dotenv(dotenv_path="/path/to/config/.env")

# Load and override existing environment variables
load_dotenv(override=True)

The override parameter controls whether values in the .env file overwrite already-set environment variables. By default, if DATABASE_PASSWORD is already set in your shell, load_dotenv() respects that and does not override it. Set override=True to force .env values to take precedence.

Protecting Your .env File

The most critical step: add .env to your .gitignore file. If your repository already has a Git history, also remove it with:

git rm --cached .env

A comprehensive .gitignore for Python projects:

# .env and local secrets
.env
.env.local
.env.*.local
env/

# IDE and OS files
.vscode/
.idea/
*.swp
*.swo
.DS_Store

# Python
__pycache__/
*.py[cod]
*$py.class
venv/
ENV/

Commit a .env.example file (with dummy values) so team members know which variables to set:

DATABASE_PASSWORD=change_me
DATABASE_HOST=localhost
API_KEY=sk_test_12345
DEBUG=true

This .env.example stays in version control. New developers run cp .env.example .env and fill in real values.

Advanced Patterns: Multiple Environments and Validation

For multi-environment setups (development, staging, production), use environment-specific .env files:

from dotenv import load_dotenv
import os

# Load .env.local first (Git-ignored), then .env.{ENVIRONMENT}
env = os.getenv("ENVIRONMENT", "development")

# Load base .env
load_dotenv(dotenv_path=".env", override=False)

# Load environment-specific overrides
load_dotenv(dotenv_path=f".env.{env}", override=True)

db_host = os.getenv("DATABASE_HOST")
print(f"Running in {env} with database {db_host}")

Files to commit: .env.example, .env.development, .env.staging.
Files to ignore: .env, .env.local, .env.production.

Validate required variables early:

from dotenv import load_dotenv, dotenv_values
import os

load_dotenv()

# dotenv_values returns a dict without setting os.environ
env_dict = dotenv_values(".env")

required = ["DATABASE_PASSWORD", "API_KEY"]
missing = [k for k in required if not os.getenv(k)]

if missing:
raise RuntimeError(
f"Missing required environment variables in .env: {missing}"
)

Why NOT to Use .env in Production

A .env file is convenient for development but unsuitable for production:

  1. Security at scale: Secrets stored in plaintext files are vulnerable if someone gains filesystem access. Production environments use encryption (encrypted databases, vaults, cloud secret managers).

  2. Rotation difficulty: Changing a secret in a plaintext file requires redeploying your entire application. Secret managers support live rotation without redeployment.

  3. Audit trail: .env files do not log who accessed or changed secrets. Production secret managers provide full audit logs for compliance (PCI-DSS, HIPAA, SOC 2).

  4. Accidental commits: Despite best efforts, developers sometimes commit .env to Git. Once it's in the history, it's compromised. Use .env only for non-sensitive local development.

In production, inject secrets via:

  • Container environment variables (set in docker run -e or docker-compose.yml)
  • Kubernetes Secrets (mounted as environment variables or volumes)
  • AWS Systems Manager Parameter Store or Secrets Manager
  • HashiCorp Vault
  • CI/CD platform secret vaults (GitHub Actions secrets:, GitLab CI variables:)

Comparison: .env vs Other Local Configuration Methods

MethodSetup TimeSecurityReadabilityTeam Sharing
.env with dotenv2 minutesPoor (plaintext)ExcellentGood (use .env.example)
Shell export before run5+ minutes per sessionFairPoorPoor (hard to document)
Configuration class (hardcoded)5 minutesBad (in repo)GoodBad (exposes secrets)
direnv (.envrc)10 minutesPoor (plaintext)GoodFair (auto-loads)
python-decouple with .env3 minutesPoorGoodGood

The .env + python-dotenv approach is the de facto standard in Python development precisely because it is easy to understand and reasonably secure for local work.

Key Takeaways

  • python-dotenv loads a .env file into environment variables at startup, eliminating the need to manually set shell variables during development.
  • Always add .env to .gitignore and commit a .env.example template instead, so team members know which variables to configure.
  • Use load_dotenv(override=False) to respect already-set environment variables, allowing shell variables to take precedence.
  • For multi-environment setups, use separate .env.development, .env.staging files and load them in order, with later ones overriding earlier values.
  • Never use .env files in production; instead, inject secrets via container platforms, Kubernetes, or cloud secret managers.

Frequently Asked Questions

What happens if I call load_dotenv() multiple times?

Each call reloads the file. If override=False (the default), earlier values are kept; if override=True, the latest file wins. Call it once at application startup and only change it during testing.

Can I use .env files with environment variable expansion?

Yes, but only limited expansion. load_dotenv() supports ${VAR} or $VAR syntax: DATABASE_URL=postgres://${DATABASE_HOST}:5432/mydb. For complex interpolation, use Pydantic or a configuration library.

How do I keep secrets out of log files when using python-dotenv?

Same approach as environment variables: never log the entire os.environ dict. Filter keys matching *PASSWORD, *KEY, *TOKEN. Use a custom logger formatter or a library like pythonjsonlogger with redaction.

Does python-dotenv work with multi-line secrets (like private keys or certificates)?

Yes. For certificates or private keys, base64-encode the content or use escaped newlines: CERT="-----BEGIN CERT-----\nMIID...\n-----END CERT-----". In Python, base64.b64decode(os.getenv("CERT_B64")) recovers the raw bytes.

How do I debug which .env file load_dotenv() loaded?

Add debug logging or check dotenv.dotenv_values() return value. For explicit checking, call load_dotenv(dotenv_path=".env", verbose=True) if your version supports it, or manually validate: if not os.getenv("DATABASE_PASSWORD"): raise RuntimeError("Loading .env failed").

Further Reading