Hardcoding config is easy until you deploy. Here's how to do it properly.

The Problem

# Don't do this
DATABASE_URL = "postgres://user:password@localhost/mydb"
API_KEY = "sk-1234567890"

This breaks when you:

  • Deploy to production
  • Share code with others
  • Rotate credentials
  • Run tests

Environment Variables 101

Environment variables are key-value pairs set outside your code:

export DATABASE_URL="postgres://..."
export API_KEY="sk-..."

Access them in Python:

import os
 
database_url = os.environ["DATABASE_URL"]
api_key = os.environ["API_KEY"]

Use os.environ.get() for Optional Values

# Crashes if missing
required_key = os.environ["API_KEY"]
 
# Returns None if missing
optional_key = os.environ.get("OPTIONAL_KEY")
 
# Returns default if missing
debug = os.environ.get("DEBUG", "false")

Always use .get() with a default for optional config.

python-dotenv for Local Development

Install:

pip install python-dotenv

Create .env file:

DATABASE_URL=postgres://localhost/mydb
API_KEY=sk-dev-key
DEBUG=true

Load in your code:

from dotenv import load_dotenv
import os
 
load_dotenv()  # Load .env file
 
database_url = os.environ["DATABASE_URL"]

Critical: Add .env to .gitignore. Never commit secrets.

The .env.example Pattern

Commit a template without real values:

# .env.example (committed)
DATABASE_URL=postgres://user:pass@localhost/dbname
API_KEY=your-api-key-here
DEBUG=false

New developers copy it:

cp .env.example .env
# Edit .env with real values

Type Conversion

Environment variables are always strings:

# This is a string "true", not boolean True
debug = os.environ.get("DEBUG", "false")
 
# Convert properly
debug = os.environ.get("DEBUG", "false").lower() == "true"
 
# For integers
port = int(os.environ.get("PORT", "8000"))
 
# For lists
allowed_hosts = os.environ.get("ALLOWED_HOSTS", "").split(",")

A Config Module

Centralize your configuration:

# config.py
import os
from dotenv import load_dotenv
 
load_dotenv()
 
class Config:
    DATABASE_URL = os.environ["DATABASE_URL"]
    API_KEY = os.environ["API_KEY"]
    DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
    PORT = int(os.environ.get("PORT", "8000"))
    
    @classmethod
    def validate(cls):
        """Fail fast if required config is missing."""
        required = ["DATABASE_URL", "API_KEY"]
        missing = [k for k in required if not os.environ.get(k)]
        if missing:
            raise ValueError(f"Missing required env vars: {missing}")

Use it:

from config import Config
 
Config.validate()  # Call at startup
db = connect(Config.DATABASE_URL)

Pydantic Settings (Production-Grade)

For larger projects, use pydantic-settings:

pip install pydantic-settings
from pydantic_settings import BaseSettings
 
class Settings(BaseSettings):
    database_url: str
    api_key: str
    debug: bool = False
    port: int = 8000
    
    class Config:
        env_file = ".env"
 
settings = Settings()
 
# Now you have validated, typed config
print(settings.database_url)
print(settings.debug)  # Already a bool

Pydantic gives you:

  • Automatic type conversion
  • Validation
  • Clear error messages
  • IDE autocomplete

The 12-Factor App Principles

From 12factor.net:

  1. Store config in environment - not in code
  2. Strict separation - same code runs in all environments
  3. No config groups - each var is independent

This means:

  • Don't have "dev config" vs "prod config" files
  • Each environment sets its own variables
  • Code doesn't know what environment it's in

Secrets Management

For production, don't put secrets in plain files:

AWS: Use Parameter Store or Secrets Manager GCP: Use Secret Manager Heroku: Use config vars Docker: Use secrets or environment injection Kubernetes: Use Secrets

Your deployment pipeline injects these at runtime.

Testing with Environment Variables

Override for tests:

# test_config.py
import os
import pytest
 
@pytest.fixture(autouse=True)
def env_setup(monkeypatch):
    monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
    monkeypatch.setenv("API_KEY", "test-key")

Or use a .env.test file:

from dotenv import load_dotenv
 
load_dotenv(".env.test")

Common Mistakes

Committing .env files

# .gitignore
.env
.env.local
.env.*.local

Not validating at startup

# Fail fast, not when you first use the value
Config.validate()

Mixing config and code

# Bad - config logic in application code
if os.environ.get("ENV") == "production":
    do_thing()
 
# Better - config exposes a flag
if Config.ENABLE_FEATURE_X:
    do_thing()

Not having defaults for optional config

# Crashes if missing
timeout = int(os.environ["TIMEOUT"])
 
# Safe
timeout = int(os.environ.get("TIMEOUT", "30"))

My Setup

  1. .env.example in repo (template)
  2. .env for local dev (gitignored)
  3. config.py module that loads and validates
  4. pydantic-settings for complex projects
  5. Secrets manager for production

Start simple, add complexity when needed.

React to this post: