Skip to content

python-dotenv

python-dotenv lets a Python program read a .env file and inject those values into os.environ. It is a convenient way to keep local configuration out of source code without changing the rest of the app.

Quick Reference

Task Typical approach
Install pip install python-dotenv
Load a .env file dotenv.load_dotenv() or dotenv.find_dotenv()
Read a variable os.getenv("VAR") or os.environ["VAR"]
Override from CLI set the env var before running the script
No .env file the call is a no-op if the file is missing (by default)

Why Use It

  • Keep secrets and environment-specific values out of code.
  • Have a default local configuration that is easy to edit.
  • Make local development closer to how production config is injected.
  • Avoid committing per-machine settings to version control.

A .env file is not a production secret store by itself. In production, prefer proper secret management. python-dotenv is mostly a local/development convenience and a bridge to the same os.environ interface used everywhere else.

Install

pip install python-dotenv

Minimal Example

.env:

DATABASE_URL=sqlite:///example.db
LOG_LEVEL=INFO
API_TIMEOUT=30

Python:

import os
from dotenv import load_dotenv

load_dotenv()

database_url = os.getenv("DATABASE_URL")
log_level = os.getenv("LOG_LEVEL", "INFO")
timeout = int(os.getenv("API_TIMEOUT", "30"))

load_dotenv() by default looks for a .env file in the current working directory.

Loading Patterns

Default location

from dotenv import load_dotenv
load_dotenv()

Loads .env from the current directory if present.

Explicit path

from dotenv import load_dotenv
load_dotenv(dotenv_path="config/local.env")

Use this when the file is in a known non-current location.

Find the project root

A common pattern for repos with deeper layouts:

from dotenv import find_dotenv, load_dotenv
load_dotenv(find_dotenv())

find_dotenv() searches upward for a .env file. This helps when the script is run from a subdirectory.

Load once, early

Load environment variables near startup, before configuration is parsed. Loading late can create a confusing gap where some code uses .env values and some does not.

Variable Precedence

Environment variables already set in the process take precedence over .env values by default.

That means: - .env is a convenient default. - Real env vars can override them. - You can run with DATABASE_URL=... python app.py and bypass the file.

This is usually what you want: the file sets defaults, and real environment variables win in production or in CI.

If you want .env to overwrite existing values, use override=True:

load_dotenv(override=True)

Use that deliberately. It can be useful for tests or local resets, but it can also hide the fact that the real environment is not behaving as expected.

Common Syntax

KEY=value
SECRET_KEY=abcdef123456
PORT=8000
DEBUG=false
MESSAGE=hello world
MULTILINE="line one
line two"
EXPORT_NOT_USED=1

Notes: - No quoting is required for simple values. - Quoting can help with spaces or special characters. - Lines starting with # are comments. - Empty lines are ignored. - export prefixes are not used by python-dotenv itself in the normal loading model; they are just text in the file.

Multiline Values

OPENAI_SYSTEM_PROMPT="You are a concise assistant.
Keep answers short.
Do not explain unless asked."

Multiline values are supported when quoted. Use them for templates, prompts, or small configuration blobs that are easier to keep in a file than inline in code.

Keep in mind: - Indentation and newlines become part of the value. - The exact result depends on quoting style. - Large configuration blobs may be better stored elsewhere.

Interpolation

Some .env setups support variable interpolation, but python-dotenv's behavior is intentionally simple. If you need a value to reference another variable, compute it in Python instead of relying on fragile file-time substitution.

Example in Python:

import os
from dotenv import load_dotenv

load_dotenv()

host = os.getenv("DB_HOST", "localhost")
port = os.getenv("DB_PORT", "5432")
database_url = f"postgresql://{host}:{port}/mydb"

This is clearer and less error-prone than expecting a .env file to do substitution.

Parsing Values

.env values are strings. Convert them explicitly.

import os
from dotenv import load_dotenv

load_dotenv()

debug = os.getenv("DEBUG", "false").strip().lower() in {"1", "true", "yes"}
port = int(os.getenv("PORT", "8000"))
rate_limit = float(os.getenv("RATE_LIMIT", "100"))

Do not assume "false" or "0" will be falsy automatically. Explicit parsing avoids a whole class of configuration bugs.

Using with argparse or config objects

A common pattern is to read env vars as defaults and then let CLI arguments override them:

import argparse
import os
from dotenv import load_dotenv

load_dotenv()

parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000")))
parser.add_argument("--debug", action="store_true", default=os.getenv("DEBUG", "false").lower() == "true")
args = parser.parse_args()

This gives a clear precedence chain: 1. .env provides the default. 2. CLI flags override it. 3. Production environments set the real env vars directly.

No .env File in Production

A .env file is usually not the right production mechanism. Typical production setups: - Inject variables directly into the environment. - Use secret managers. - Use orchestration config. - Mount config files where the app reads them.

python-dotenv should not be relied on as the only production configuration path. Structure your app so it reads from os.environ either way, and treat .env as the local file-based fallback.

Security Considerations

  • Do not commit real secrets to .env if it is tracked in version control.
  • Add .env to .gitignore unless you intentionally ship a template.
  • Keep a .env.example with placeholder values for collaborators.
  • Do not assume .env provides real security boundaries.
  • Rotate secrets and avoid reusing them across environments.

A typical repo layout:

.env               # gitignored, real local values
.env.example       # tracked, placeholder values
config/
src/

Testing and Dotenv

For tests, you can: - Load a test .env file. - Override variables with override=True. - Set environment variables before the test run. - Isolate test configuration from local development configuration.

Avoid tests that depend on the user's personal .env file. That makes tests brittle and hard to share.

Example:

def test_with_fake_env(monkeypatch):
    monkeypatch.setenv("API_TIMEOUT", "5")
    assert api_timeout() == 5

When a library or module already called load_dotenv() at import time, patching os.environ may still be the cleanest way to control behavior in a test.

Common Mistakes

  • Expecting .env to work in production without real env injection.
  • Committing secrets into .env and then into git.
  • Parsing booleans and numbers implicitly instead of explicitly.
  • Loading .env in the middle of the program instead of early.
  • Using override=True without understanding that it hides the real environment.
  • Putting large config blobs in .env instead of a proper config file.
  • Relying on interpolation that the library does not actually provide.
  • Having different .env conventions across developers and never reconciling them.
  • yaml — structured config files
  • toml — another common config format
  • make — orchestrating commands including env-aware runs
  • curl — testing endpoints that may depend on env-derived config
  • ssh — remote environments where env vars are often set directly
  • fastapi — configuration patterns in a common Python service framework

Official Documentation

The library is small. The bigger win is using it as part of a clear configuration story rather than as a replacement for one.

Last Updated

2026-09-06 — Initial version.