pyguides

Managing Config with python-dotenv

python-dotenv simplifies managing config in Python by loading 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. This installs the package globally or into your active virtual environment, depending on your current Python setup. The package is lightweight and has no dependencies beyond the standard library, so installation is quick and conflict-free in most environments.

pip install python-dotenv

Getting Started

Creating a .env file

Create a file named .env in your project root directory. This file uses a simple KEY=VALUE format where each line defines one environment variable. The values you put here will be loaded into os.environ when your application calls load_dotenv(), making them accessible to any code that reads environment variables. The example below shows three typical entries: a database connection string, an API key, and a boolean flag for debug mode.

# .env
DATABASE_URL=************************************/mydb
API_KEY=your-secret-api-key-here
DEBUG=True

Important: Add .env to .gitignore

Your .env file contains secrets that should never appear in version history. Adding it to .gitignore is not optional, it is the single most important security practice when using dotenv. If you accidentally commit a .env file with real credentials, assume those credentials are compromised and rotate them immediately. The .gitignore entry below ensures Git ignores the file in all directories, not just the project root.

# .gitignore
.env

Loading variables in Python

Import load_dotenv and call it at the very start of your application, before any other code that reads environment variables. The function scans the .env file in the current working directory (or a path you provide), parses each line, and injects the key-value pairs into os.environ. After calling load_dotenv(), you access the variables through os.getenv() just as you would with any other environment variable. If the .env file does not exist, the function returns silently without raising an error, which is intentional, it means your application still runs in environments where configuration comes from elsewhere, such as a container orchestrator or a CI system.

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. This silent-failure behavior is by design: it allows the same code to run in environments where configuration comes from elsewhere, such as a container orchestrator or a CI pipeline that injects variables directly rather than using a .env file.

Finding the .env File

By default, load_dotenv() looks for .env in the current working directory, which is the directory from which you launched the Python process. This can be surprising if you run your script from a different directory than where the .env file lives. To avoid this ambiguity, specify the path explicitly using an absolute or computed relative path. The example below shows both approaches: a hard-coded absolute path for development, and a path computed relative to the script’s own location using os.path.dirname(__file__), which works regardless of where the process was launched from.

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 dictionary-style access via os.environ rather than the os.getenv() method, that works equally well because load_dotenv() updates the process environment dictionary directly. The os.environ object behaves like a regular Python dict, so you can use .get() with default values or check for key existence with in. The example below shows the direct access style, which some developers find more natural when they are already familiar with dictionary operations.

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 so your application runs correctly even when certain configuration keys are absent from the .env file. This pattern is especially useful for feature flags: you can ship a new feature behind a flag that defaults to off, and enable it selectively by adding the variable to the .env file of specific environments. The boolean conversion trick using == "True" is a common idiom because environment variables are always strings, so comparing against the string "True" is the simplest way to get a Python boolean.

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 by calling load_dotenv() multiple times with different filenames. The order of calls matters: the first call loads a base configuration, and subsequent calls override values from earlier ones. This layering approach mirrors how many frameworks handle configuration, where a base config file sets sensible defaults and an environment-specific file overrides only the values that differ for staging or production. The example below loads .env first for defaults, then loads .env.production which overwrites values like DATABASE_URL with production-specific settings.

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 relative to the project root rather than relying on the current working directory. Using pathlib.Path with __file__ lets you compute the path deterministically regardless of where the process is launched from. This approach is more reliable than hard-coding an absolute path because it works across different machines and deployment environments where the project root might be at different absolute locations on disk.

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 demonstrates how python-dotenv integrates with a web framework. The key detail is that load_dotenv() must be called before any configuration values are read, which means near the very top of the application entry point, before app = Flask(__name__). If you load the .env file after creating the Flask app, any settings that reference environment variables will see empty or default values rather than the ones from your .env file. The example below also shows a route that checks whether an API key is configured and returns a clear error if it is missing, which is better than letting a downstream service fail with an opaque authentication error.

# 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 file declares the values that the Flask application reads at startup. Notice that the SECRET_KEY and DATABASE_URL match the keys accessed by os.getenv() calls in the application code above, while the API_KEY value is redacted, in a real project you would replace the asterisks with your actual key. Each Flask configuration key gets its value from the environment, so changing the .env file is all you need to switch between development and production settings.

# .env
SECRET_KEY=your-production-secret-key
DATABASE_URL=postgresql://prod-server/mydb
API_KEY=******************

Django Example

Django applications often use python-dotenv in their settings module to avoid hard-coding secrets in settings.py. The example below places load_dotenv() near the top of the settings file so that every subsequent setting can reference environment variables. The BASE_DIR computation using Path(__file__).resolve().parent.parent finds the project root reliably, and the .env file in that directory is loaded before anything else runs. This pattern is particularly common in Django projects that follow the Twelve-Factor App methodology, where configuration is strictly separated from code.

# 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 where each line is either a comment (starting with #), a variable assignment (KEY=value), or an empty line. The parser strips leading and trailing whitespace from both keys and values, and it handles quoted values appropriately. Empty values are valid and result in an empty string in os.environ. Variable interpolation like $PATH is not supported by python-dotenv itself, although some shell environments support it when sourcing the file directly. The example below illustrates each of these formatting rules.

# 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

Whitespace handling in .env files depends on quoting. Without quotes, trailing and leading spaces around the value are trimmed, which means KEY=value with spaces becomes the string value with spaces without surrounding whitespace. Double quotes preserve the value exactly as written, including any internal spaces. Single quotes prevent variable interpolation and treat every character literally, a $ inside single quotes is just a dollar sign, not a variable reference. The examples below demonstrate each quoting style to help you avoid subtle configuration bugs caused by unintended whitespace.

# 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 means if you set DATABASE_URL in your shell before running the script, that value takes priority over whatever is in the .env file. This is useful in production where the deployment platform injects variables directly. When you need the .env file to win regardless of existing values, for example, in a test suite where you want clean, predictable state, pass override=True to force every variable in the file to overwrite anything already in the environment.

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 are not loading as expected, enable verbose mode. This prints each key-value pair that python-dotenv reads from the .env file to standard output, so you can confirm that the file was found, parsed correctly, and that the values match what you intended. The output can be noisy in production, but during development and troubleshooting it saves you from guessing whether a missing variable is the result of a typo in the .env file or a path resolution error.

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 can combine python-dotenv with Docker’s built-in environment variable handling. The Dockerfile below copies the application code into the image, installs dependencies, and declares environment variables that can be overridden at container startup. Building configuration into the image via ARG and ENV gives you sensible defaults, while the -e flag at runtime lets you inject environment-specific values without rebuilding.

# 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 using the -e flag. This approach works well for sensitive values like database passwords that should never be baked into an image layer. The values you pass on the command line override any ENV declarations in the Dockerfile.

docker run -e DATABASE_URL="postgresql://..." -e API_KEY="..." myapp

Or use Docker Compose for local development. The env_file directive in the compose file reads a .env file from your project directory and injects all its variables into the container, which is convenient when you already have a .env file for local development and want Docker to consume it without manually passing each variable.

# 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 because it operates entirely within the Python process and does not depend on shell configuration or system-level environment files. Once you activate a virtual environment, install the package, and call load_dotenv() in your application code, the variables from your .env file are available to every module in that process regardless of how the virtual environment was created. The sequence below shows the standard workflow from scratch.

# 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 to support per-developer overrides on top of shared environment configuration. The base .env file contains the defaults that everyone on the team shares, while a .env.development.local file (which is gitignored) lets each developer add personal overrides without affecting anyone else. The override=True parameter ensures the local file wins over the base file for any keys that appear in both.

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.

Managing config patterns

Centralized Config

For larger applications, centralizing all configuration access through a single module prevents the scattered os.getenv() calls that make it hard to know which environment variables your application actually depends on. The example below wraps load_dotenv() and all variable access in a cached factory function, so the .env file is loaded once and the resulting configuration object is reused across every module that imports it. The @lru_cache decorator ensures the function body runs only on the first call, making subsequent accesses essentially free.

# 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. This pattern uses inheritance to share common settings in a base class while letting each environment subclass override specific values. The APP_ENV variable determines which configuration class is instantiated at runtime, so the same code can run in development, staging, or production without any code changes, only a different environment variable.

# 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

  1. Never commit secrets: Add .env to .gitignore
  2. Use .env.example: Commit a template file with placeholder values so developers know what variables are needed:
    # .env.example
    DATABASE_URL=
    API_KEY=
  3. Use different files for environments: .env.development, .env.production
  4. Validate required variables: Check for required keys at startup so your application fails immediately with a clear message rather than crashing later with a cryptic NoneType error deep in the code. The list comprehension below collects all missing variables into a single error message, which is far more useful than discovering missing configuration one variable at a time through successive crashes.
    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}")

Conclusion

Managing config with python-dotenv keeps your environment variables organized and your secrets out of version control. Whether you are building a small script or a large Django application, loading configuration from .env files gives you a clean separation between code and environment-specific settings that works across development, staging, and production.

See also