env.dev

Python Env Variables: os.environ, dotenv & Pydantic

How to read, set, and manage environment variables in Python. Covers os.environ, python-dotenv, Pydantic Settings v2, and Django/Flask configuration patterns.

Last updated:

Python reads environment variables through the os module, but real-world projects rarely stop there. Libraries like python-dotenv, pydantic-settings, and django-environ add file loading, type coercion, and validation on top. Every environment variable is a string at the OS level, so converting to the correct Python type is your responsibility. This guide covers the standard library, the most popular third-party tools, framework-specific patterns for Django and Flask, and the mistakes that catch developers off guard.

How do os.environ and os.getenv() differ?

The os module provides two ways to read environment variables. os.environ is a dict-like mapping that raises KeyError when a variable is missing. os.getenv() returns None (or a default you specify) instead of raising.

python
import os

# Raises KeyError if DATABASE_URL is not set
db_url = os.environ['DATABASE_URL']

# Returns None if missing — no exception
db_url = os.getenv('DATABASE_URL')

# Returns a fallback value if missing
db_url = os.getenv('DATABASE_URL', 'sqlite:///local.db')

# You can also use .get() on os.environ directly
db_url = os.environ.get('DATABASE_URL', 'sqlite:///local.db')

# Set a variable for the current process
os.environ['APP_MODE'] = 'debug'

Use os.environ['VAR'] when the variable is required and the application should crash immediately if it is missing. Use os.getenv() when a sensible default exists.

How do you load a .env file with python-dotenv?

The python-dotenv package reads key-value pairs from a .env file and injects them into os.environ. Install it with pip install python-dotenv.

python
from dotenv import load_dotenv
import os

# Load .env from the current working directory
load_dotenv()

# Or specify an explicit path
load_dotenv('/app/config/.env')

# By default, existing env vars are NOT overridden.
# Pass override=True to replace existing values.
load_dotenv(override=True)

# Now read variables normally
secret_key = os.getenv('SECRET_KEY')

The load_dotenv() call must happen before you read any variables. A common pattern is to call it at the top of your entry point module. See the .env guide for an overview, or the .env file syntax reference for the exact rules around quoting, escaping, and comments.

How does Pydantic Settings handle env vars?

pydantic-settings (install with pip install pydantic-settings) combines loading, type coercion, and validation in a single class. It reads from environment variables and .env files automatically.

python
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    debug: bool = False
    max_connections: int = 10
    allowed_hosts: list[str] = ['localhost']

    model_config = {
        'env_file': '.env',
        'env_file_encoding': 'utf-8',
    }

# Pydantic reads DATABASE_URL, DEBUG, MAX_CONNECTIONS from the environment,
# converts them to the annotated types, and validates them.
settings = Settings()

print(settings.debug)            # bool, not str
print(settings.max_connections)  # int, not str

Field names are matched case-insensitively by default (database_url matches DATABASE_URL). Pydantic raises a ValidationError at instantiation if required fields are missing or values cannot be coerced, so you get fail-fast behavior without writing manual checks.

What is the Django pattern with django-environ?

django-environ wraps environment variable reading with type-casting helpers designed for settings.py. Install with pip install django-environ.

python
# settings.py
import environ

env = environ.Env(
    DEBUG=(bool, False),
    ALLOWED_HOSTS=(list, ['localhost']),
)

# Read the .env file
environ.Env.read_env('.env')

SECRET_KEY = env('SECRET_KEY')
DEBUG = env('DEBUG')                          # bool
ALLOWED_HOSTS = env('ALLOWED_HOSTS')          # list
DATABASES = {
    'default': env.db('DATABASE_URL'),        # parses database URLs
}
CACHES = {
    'default': env.cache('CACHE_URL'),        # parses cache URLs
}
EMAIL_CONFIG = env.email('EMAIL_URL')         # parses email URLs

The env.db(), env.cache(), and env.email() helpers parse URL-style connection strings directly into the dict format Django expects, eliminating boilerplate.

How do you configure Flask with environment variables?

Flask does not auto-load .env files. The standard approach is to call load_dotenv() before creating the app, then use app.config.from_prefixed_env() or read values directly.

python
from dotenv import load_dotenv
load_dotenv()

from flask import Flask
import os

app = Flask(__name__)

# Option 1: read individual vars
app.config['SECRET_KEY'] = os.environ['SECRET_KEY']
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL')

# Option 2: load all FLASK_ prefixed vars (strips the prefix)
# FLASK_SECRET_KEY in .env becomes app.config['SECRET_KEY']
app.config.from_prefixed_env()

# Option 3: load from a config object
class Config:
    SECRET_KEY = os.environ['SECRET_KEY']
    SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL', 'sqlite:///app.db')
    DEBUG = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'

app.config.from_object(Config)

Do virtual environments affect environment variables?

Python virtual environments (venv, virtualenv) do not isolate environment variables. A venv only changes PATH and VIRTUAL_ENV so that the correct Python interpreter and packages are used. All other environment variables are inherited from the parent shell.

python
# These are NOT set by activating a venv — they come from the shell
os.getenv('DATABASE_URL')  # inherited from parent process
os.getenv('VIRTUAL_ENV')   # set by the activate script

# To set env vars per-project, use a .env file with python-dotenv
# or export them in the activate script:
# echo 'export DATABASE_URL=postgres://...' >> .venv/bin/activate

How do you handle type conversion?

Environment variables are always strings. Forgetting this leads to subtle bugs, especially with booleans. The string "false" is truthy in Python.

python
import os

# BUG: bool('false') is True — any non-empty string is truthy
debug = bool(os.getenv('DEBUG', 'false'))  # True!

# Correct: compare the string value
debug = os.getenv('DEBUG', 'false').lower() in ('true', '1', 'yes')

# Integers
max_conn = int(os.getenv('MAX_CONNECTIONS', '10'))

# Floats
timeout = float(os.getenv('TIMEOUT_SECONDS', '30.0'))

# Lists (common convention: comma-separated)
hosts = os.getenv('ALLOWED_HOSTS', 'localhost').split(',')

# Use Pydantic Settings to avoid manual conversion entirely
Watch out: bool(os.getenv('DEBUG')) returns True for any non-empty string, including "false", "0", and "no". Always compare against explicit string values.

How do you set default values and require variables?

Choose deliberately between optional variables with defaults and required variables that must be set.

python
import os
import sys

# Optional with default
log_level = os.getenv('LOG_LEVEL', 'INFO')

# Required — crash immediately with a clear message
database_url = os.environ.get('DATABASE_URL')
if not database_url:
    sys.exit('DATABASE_URL environment variable is required')

# Required — one-liner using os.environ (raises KeyError)
secret_key = os.environ['SECRET_KEY']

# With Pydantic: fields without defaults are required
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    secret_key: str              # required — ValidationError if missing
    debug: bool = False          # optional with default
    log_level: str = 'INFO'      # optional with default

How do you test with environment variables?

Tests should never depend on the developer's actual environment. Use monkeypatch in pytest or unittest.mock.patch.dict to set, override, or remove variables within a test scope.

python
# pytest — monkeypatch (recommended)
def test_database_config(monkeypatch):
    monkeypatch.setenv('DATABASE_URL', 'sqlite:///test.db')
    monkeypatch.setenv('DEBUG', 'true')

    from myapp.config import get_settings
    settings = get_settings()
    assert settings.debug is True

def test_missing_var(monkeypatch):
    monkeypatch.delenv('SECRET_KEY', raising=False)
    # Test that your app raises on missing required vars

# unittest — mock.patch.dict
from unittest.mock import patch

@patch.dict('os.environ', {'API_KEY': 'test-key-123'})
def test_api_client():
    import os
    assert os.environ['API_KEY'] == 'test-key-123'

@patch.dict('os.environ', {}, clear=True)
def test_empty_environment():
    import os
    assert os.getenv('DATABASE_URL') is None

Both approaches restore the original environment after the test exits, even if the test fails.

What are the most common mistakes?

Forgetting to call load_dotenv()

Installing python-dotenv does nothing by itself. You must call load_dotenv() before reading variables. Place it at the very top of your entry point, before any imports that use env vars.

Wrong .env file path

load_dotenv() searches from the current working directory, which may differ depending on how you launch your app. Use load_dotenv(Path(__file__).resolve().parent / '.env') for a path relative to the source file. For a deeper walkthrough of path-related failures, see dotenv not loading? debugging guide.

Treating env vars as non-strings

os.getenv('PORT') returns "8000", not 8000. Passing it directly to a function expecting an int will fail or silently behave incorrectly.

Committing .env to version control

Add .env to .gitignore immediately. Commit a .env.example with placeholder values instead. If secrets were already committed, rotate them — deleting the file from the repo does not remove it from git history.

Reading env vars at import time

Module-level code like DB_URL = os.getenv('DATABASE_URL') runs when the module is first imported. If load_dotenv() has not been called yet, the value will be None. Use functions or lazy patterns to defer reading until after initialization.

References

Check your .env files for syntax errors with the env validator, or read the .env guide for full dotenv syntax and cross-language usage.

Was this helpful?

Read next

Node.js Env Variables: process.env, dotenv & --env-file

How to use environment variables in Node.js: process.env, dotenv, the Node 20.6+ --env-file flag, NODE_ENV, type-safe validation with zod.

Continue →

Frequently Asked Questions

How do I read an environment variable in Python?

Use os.environ["KEY"] to read a variable (raises KeyError if missing) or os.getenv("KEY", "default") to read with a fallback default value. Import os first.

How do I use .env files in Python?

Install python-dotenv (pip install python-dotenv) and call load_dotenv() at the top of your entry file before accessing any environment variables. It reads .env and sets the values in os.environ.

What is Pydantic Settings?

Pydantic Settings (pydantic-settings package) provides type-safe environment variable parsing. Define a class extending BaseSettings with typed fields, and Pydantic automatically reads from environment variables, validates types, and provides defaults.

Should I use python-dotenv or Pydantic Settings?

For small scripts and simple apps, python-dotenv is sufficient. For applications that need validation, type safety, and nested configuration, pydantic-settings is the better choice. Pydantic Settings can load .env files directly, so you do not need both.

Can I have multiple .env files in Python?

Yes. Call load_dotenv(".env") and load_dotenv(".env.local", override=True) in sequence. Pydantic Settings accepts a list: env_file = (".env", ".env.local"). Later files take priority.

Why is bool(os.getenv('DEBUG')) always True?

Because environment variables are always strings, and any non-empty string is truthy in Python. bool("false"), bool("0"), and bool("no") all return True. Compare against explicit string values instead, e.g. os.getenv("DEBUG", "false").lower() in ("true", "1", "yes").

Stay up to date

Get notified about new guides, tools, and cheatsheets.