Use python-dotenv safely
Learn how to use python-dotenv securely: load environment variables without exposing secrets, avoid common pitfalls, and follow best practices for managing configuration in development.
Focus: use python-dotenv safely
You’ve refactored your Python app, extracted the hardcoded credentials into a .env file, and installed python-dotenv. But here’s the catch: you don’t “use python-dotenv safely” just by calling load_dotenv(). One wrong import, one committed .env, or one stray print statement can leak your database password, API keys, or tokens to the world. In this lesson, you’ll learn how to use python-dotenv safely — protecting secrets during development while keeping your code clean and deployable.
The problem this lesson solves
Every developer has done it: hardcoded credentials in source code. It’s quick, it works locally, and it’s a ticking time bomb. When you push to GitHub, the secret goes with it. Tools exist to clean up history, but the damage may already be done — tokens get rotated, clients get notified, and your trust takes a hit.
python-dotenv solves the immediate problem of loading configuration from a .env file instead of hardcoding. But it introduces a new set of risks:
- Accidental exposure — your
.envfile ends up in version control. - Silent failures — a missing
.envcauses confusing runtime errors. - Secret leakage — your code logs
os.environor prints config values. - Insecure defaults — you hardcode fallback values that override the environment.
When you use python-dotenv without intentional guardrails, you trade one vulnerability for another. This lesson gives you the mental model and practical steps to use it safely.
Why this matters now
Your app might still be in development, but secrets in code don’t care about your timeline. A single commit to a public repository can expose credentials within minutes — bots scan for them. Building the secure habit now, before you scale, is far cheaper than incident response later.
Core concept / mental model
Think of python-dotenv as a key-value loader for your local environment. It reads a file called .env in your project root, parses lines like DATABASE_URL=postgres://..., and sets them as environment variables for your process. Once loaded, your code accesses them via os.getenv("DATABASE_URL") — just like any other environment variable.
The mental model breaks into three layers:
- Source — the
.envfile, which contains secret values in plain text. - Loader —
python-dotenvreads the file and populatesos.environ. - Consumer — your application code reads those variables via
os.getenvoros.environ.
The danger? The source is unencrypted and often sits in your project directory. The consumer may inadvertently print or log those values. The loader itself is safe — but only if you follow strict rules around file handling and precedence.
Defining key terms
- Environment variable — a dynamic value stored in the operating system or process environment, accessible via
os.environ. .envfile — a plain-text file withKEY=VALUEpairs used in development..gitignore— a file that tells Git which files to ignore — your first line of defense.load_dotenv()— the function that reads.envand updatesos.environ(by default, without overwriting existing variables).
How it works step by step
When your Python application starts, here’s what happens if you call load_dotenv() at the top:
- Locate —
python-dotenvsearches for a file named.envin the current directory (or a parent, depending onfind_dotenv()). - Parse — it reads each line, ignores comments (starting with
#) and blank lines, and splits on the first=into a key and value. - Load — it sets each key into
os.environ— but critically, it does not override existing environment variables by default (setoverride=Trueto force). - Cleanup — it does not delete any variables; it simply adds what’s missing.
The beauty is that you get consistent configuration across team members — everyone uses the same .env.template for non-secret keys, and each dev fills in their own secrets locally.
The safety rules
- Rule 1: Never commit
.env. Add it to.gitignoreimmediately after creating it. - Rule 2: Provide a
.env.example— a template with placeholder values (e.g.,DATABASE_URL=postgres://user:pass@localhost/db), so new developers know what to set. - Rule 3: Use
load_dotenv()withoverride=False(the default) so real environment variables take precedence — this allows you to run in production without a.envfile. - Rule 4: Avoid dynamic loading in production — set environment variables via your deployment platform (Kubernetes, Docker, CI/CD) and skip
load_dotenv()entirely in production.
Hands-on walkthrough
Let’s build a small project that uses python-dotenv safely. First, install the library:
pip install python-dotenv
Now create a structure:
myproject/
├── .env.example
├── .gitignore
├── config.py
└── app.py
Step 1: Create .env.example
# Copy this file to .env and fill in your real values
DATABASE_URL=postgres://user:password@localhost:5432/mydb
API_KEY=your-api-key-here
DEBUG=False
Step 2: Create .gitignore
.env
__pycache__/
*.pyc
.venv/
This is your first security layer — Git will ignore .env, so your secrets never get committed (provided you don’t force-add).
Step 3: Write config.py
from dotenv import load_dotenv
import os
# Only load .env if it exists; override=False (default) preserves real env vars
load_dotenv()
def get_setting(key: str, default: str | None = None) -> str | None:
"""Safely retrieve a setting without logging its value."""
return os.getenv(key, default)
Step 4: Use it in app.py
from config import get_setting
# Never print the actual value!
database_url = get_setting("DATABASE_URL", "postgres://localhost/default")
api_key = get_setting("API_KEY")
if not api_key:
raise RuntimeError("API_KEY is not set. Check your .env file.")
# Use the values in your code
print("Database configured:", bool(database_url))
print("API key loaded:", bool(api_key))
Expected output (assuming you created .env with a value):
Database configured: True
API key loaded: True
The bool() trick lets you confirm a variable exists without exposing its value — essential for sanity checks in logs.
Step 5: Run securely
cp .env.example .env # then edit .env with your real secrets
python app.py
Now, even if you accidentally print os.environ, the loaded values are only in memory — but we avoid that with the wrapper function.
Compare options / when to choose what
python-dotenv is not the only way to manage configuration. Here’s a comparison:
| Approach | Best for | Security concern |
|---|---|---|
python-dotenv |
Local development, simple apps | .env file must be gitignored; plain text |
| OS environment variables | Production, CI/CD | No file exposure, but must be set per environment |
Config libraries (e.g., pydantic-settings) |
Larger apps with validation | Layers safety features, but still relies on env vars or files |
| Secret managers (e.g., AWS Secrets Manager, HashiCorp Vault) | Cloud-native apps, compliance | Centralized, encrypted, but added complexity |
When to choose what:
- Use
python-dotenvwhen you need a quick, readable local setup — just never commit.env. - Use OS environment variables for production, especially in containerized environments — they come from the orchestrator.
- Use secret managers when you need rotation, auditing, or enforce strict access controls.
- For most projects, a combination works:
python-dotenvfor dev, environment variables for prod, and a secret manager for high-stakes secrets.
Variations: alternative tools
python-decouple— separates settings from code, similar philosophy, but adds typed defaults and a more opinionated API.pydantic-settings— integrates with Pydantic models, giving you validation and type coercion, while reading from.envor environment.django-environ— for Django projects, wrapsos.environand provides aPathhelper for base directory.
Troubleshooting & edge cases
Even seasoned devs trip over these — here’s how to fix them.
.env not loading
Symptom: os.getenv("API_KEY") returns None.
Fixes:
- Ensure load_dotenv() is called before any getenv in your code — order matters.
- Check the current working directory — load_dotenv() looks from the process’s CWD. Use find_dotenv() or pass an explicit path.
- Verify the file is named exactly .env (including the dot).
from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv()) # searches parent dirs too
Duplicate keys
If .env has DEBUG=True and your OS already has DEBUG=True, the file value is ignored unless override=True. This causes confusion — always document precedence.
Secrets leaking in logs
Never do this:
print(os.environ) # BAD: prints every secret
Instead, only print boolean existence flags or masked values:
api_key = os.getenv("API_KEY")
print(f"API key set: {bool(api_key)}")
Accidental commit
If you already committed .env, remove it from tracking:
git rm --cached .env
echo ".env" >> .gitignore
git commit -m "Remove .env and add to gitignore"
But be aware — the secret is in history. Rotate the credential and consider using a tool like git-filter-repo to purge history, but rotation is mandatory.
Special characters in values
Values with # or spaces need quoting. For example:
PASSWORD="my#pass word"
If you forget, python-dotenv may interpret # as a comment — test your parsing early.
What you learned & what's next
You now know how to use python-dotenv safely: you understand the mental model, the step-by-step loading process, and the critical guardrails — especially gitignoring .env and avoiding secret logs. You practiced with a complete example and learned to troubleshoot common edge cases like missing files and duplicate keys.
Key skills you gained:
- Explain the core idea behind python-dotenv as a local env loader.
- Apply safe usage patterns: .env.example, .gitignore, and non-overriding load_dotenv().
- Connect this to your deployment strategy — env vars in prod, dotenv in dev.
What’s next? In the next lesson in this Secure development track, you’ll learn to validate and sanitize configuration inputs — ensuring that even if an attacker can influence env vars, your app won’t misbehave. That’s a natural next step after securing how you load them.
Now, go check your .gitignore — and if .env isn’t there, add it right now.
Practice recap
Create a new project with a .env.example and a .gitignore. Write a Python script that uses load_dotenv() and prints only whether each required secret is set (boolean). Then simulate a production scenario by setting one of those variables in your shell and confirm it overrides the .env value. Finally, run git status to verify .env is ignored.
Common mistakes
- Committing
.envto version control — always add it to.gitignoreimmediately after creating it. - Calling
load_dotenv()after you’ve already imported modules that read env vars — order matters; call it at the absolute top of your entrypoint. - Printing
os.environor logging actual secret values — use boolean flags or masked output instead. - Using
override=Trueby default — this can unexpectedly wipe out real environment variables in production. - Relying on
.envin production — environment variables should come from the platform, not a file you forget to gitignore.
Variations
python-decouple— offers a similar API with typed defaults and a stronger separation between settings and code.pydantic-settings— loads from.envor environment with type validation and coercion, ideal for larger apps.django-environ— Django-specific wrapper that integrates with your settings module and provides aPathhelper.
Real-world use cases
- A Django web app uses
python-dotenvin development to loadSECRET_KEYand database credentials from a gitignored.envfile. - A CI/CD pipeline (e.g., GitHub Actions) sets real environment variables for production while developers use
.envlocally — withload_dotenv()skipping if already set. - A microservice reads secrets like API keys for external services; a deployment script uses
.env.exampleto scaffold new dev environments.
Key takeaways
python-dotenvis a development-time convenience — never use it as a security mechanism.- Always gitignore
.envand provide a.env.examplefor teammates. - Prefer
load_dotenv()with defaultoverride=Falseso production env vars take precedence. - Avoid logging or printing secret values; use booleans or masks.
- In production, rely on OS environment variables or a secret manager, not a file.
- Troubleshoot loading order and CWD before assuming the library is broken.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.