← All posts

python-dotenv: The Complete Guide to load_dotenv and .env Files in Python

Published

python-dotenv reads a .env file and puts its values into your environment so os.environ can see them. It is the standard way to keep configuration and secrets out of Python code during development, and it is small enough that most people never read past the first example. The details matter more than they look, especially override behaviour and what happens in production.

Install

pip install python-dotenv

The package is python-dotenv. The import is dotenv. If you get ModuleNotFoundError: No module named 'dotenv', you almost certainly installed the wrong one; the fix is here.

Basic use

A .env file in your project root:

DATABASE_URL=postgres://app:secret@localhost:5432/app
STRIPE_SECRET_KEY=sk_test_abc123
DEBUG=true

In your code, before anything reads the environment:

from dotenv import load_dotenv
import os

load_dotenv()

db = os.environ["DATABASE_URL"]
debug = os.getenv("DEBUG", "false").lower() == "true"

load_dotenv() searches for .env starting in the directory of the calling script and walking up through parents. It returns True if it found and loaded a file, False otherwise. It does not raise if the file is missing, which is deliberate: in production there is no file and the variables come from the platform.

Every value is a string. DEBUG=true gives you the string "true", which is truthy even when it says false. Convert explicitly. Python environment variables covers this and the validation pattern that avoids it.

Override behaviour

By default load_dotenv() does not overwrite a variable that is already set in the environment. If your shell has DATABASE_URL exported and your .env has a different value, the shell wins.

This is the correct default. It means platform configuration in production beats a stray .env file, and it means you can override a single value from the command line without editing the file:

DEBUG=false python app.py

If you want the file to win, say so:

load_dotenv(override=True)

Use that sparingly. It is the setting that makes "but it works on my machine" harder to debug.

Loading a specific file

load_dotenv(".env.local")

Or find it explicitly, which is useful when the script runs from a different working directory:

from dotenv import load_dotenv, find_dotenv

load_dotenv(find_dotenv())

find_dotenv() walks up from the current file. Pass usecwd=True to walk up from the current working directory instead.

Layered files work by calling it more than once. Because of the no override default, the first file loaded wins for any key present in both:

load_dotenv(".env.local")
load_dotenv(".env")

Local overrides base. Reverse the order and base would win.

Reading without touching the environment

dotenv_values() parses the file into a dictionary and leaves os.environ alone:

from dotenv import dotenv_values

config = dotenv_values(".env")
db = config["DATABASE_URL"]

Useful in tests, or when you want to merge several sources yourself:

config = {
    **dotenv_values(".env"),
    **dotenv_values(".env.local"),
    **os.environ,
}

Here later sources override earlier ones, so shell environment beats both files.

File format details

The parser is more capable than most people assume.

Comments start with #:

# Database
DATABASE_URL=postgres://localhost/app  # inline comments work too

Quotes are optional for simple values and required for values with spaces or #:

GREETING="hello world"
HASH_VALUE='contains # hash'

Double quoted values expand escape sequences like \n. Single quoted values are literal.

Multiline values work inside double quotes:

PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
-----END RSA PRIVATE KEY-----"

Variable expansion is supported:

BASE_URL=https://api.example.com
USERS_URL=${BASE_URL}/users

Expansion pulls from earlier lines in the file and from the existing environment. Disable it with load_dotenv(interpolate=False) if your values contain literal ${...}.

The export prefix is tolerated:

export API_KEY=abc123

So a file can double as a shell script, though there is rarely a reason to do this.

Framework setup

Django: call load_dotenv() at the top of settings.py, before any os.environ reads. Or use django-environ, which wraps the same idea with type casting built in.

Flask: the flask command loads .env and .flaskenv automatically if python-dotenv is installed. .flaskenv is for Flask specific settings like FLASK_APP; .env is for your app. Running python app.py directly skips this, so call load_dotenv() yourself in that case.

FastAPI: no built in loading. Use pydantic-settings, which reads .env and validates types in one step:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    debug: bool = False
    model_config = {"env_file": ".env"}

Jupyter: %load_ext dotenv then %dotenv in a cell loads the nearest .env into the kernel.

The CLI

python-dotenv ships a command:

dotenv list
dotenv set API_KEY abc123
dotenv run -- python app.py

dotenv run executes a command with the file's variables in its environment without you adding load_dotenv() to the code. Handy for scripts you do not own.

Production

Do not rely on .env in production. The platform (Railway, Heroku, AWS, Kubernetes, whatever you deploy to) has its own way to set environment variables, and that is where they should come from. Leave load_dotenv() in the code; it finds no file and does nothing. Just do not ship the file.

Sharing across a team

python-dotenv solves the one developer, one machine case completely. It does nothing for the second developer who needs the same twenty variables, or for the day the database password rotates and one of you is still using the old one.

That is where a secrets manager comes in: a shared source each developer pulls from, so the .env on your machine is generated rather than emailed. Krypt, which we build, does this with a flat price for the whole team, and this comparison covers the alternatives. If you are working alone, a .env in .gitignore is all you need.

FAQ

What is the difference between load_dotenv and dotenv_values? load_dotenv puts the values into os.environ. dotenv_values returns them as a dictionary and leaves the environment alone.

Why is my .env value being ignored? Either the variable was already set in the environment (default no override), the file is in a different directory than load_dotenv is searching, or load_dotenv() is called after the code that reads the variable.

Does python-dotenv work with Python 3.12 and 3.13? Yes.

Can I use python-dotenv in Docker? You can, but docker run --env-file .env or Compose's env_file: does the same job without a dependency. Use one or the other, not both.

Should I commit my .env file? No. Commit .env.example with placeholder values so others know which variables exist.