Back to blog
Environment Variables in Python: os.environ, dotenv, and Best Practices (2026)

Environment Variables in Python: os.environ, dotenv, and Best Practices (2026)

Complete guide to environment variables in Python. Learn os.environ vs os.getenv, python-dotenv for .env files, type conversion, structured config with dataclasses and Pydantic, and security best practices.

February 16, 2026by Patrick Gerrits
environment-variablespythondotenvconfiguration

Environment Variables in Python

You just pushed your code to GitHub. Ten seconds later, you realize the database URL with your production credentials is right there in database.py for everyone to see. Your stomach drops.

This exact scenario plays out constantly for developers who haven't learned to work with environment variables in Python. The fix is simple: separate your configuration from your code using os.environ, os.getenv(), and tools like python-dotenv. This guide shows you how to read, set, and manage environment variables in Python so you never hardcode credentials again.

Understanding os.environ vs os.getenv()

Python's os module gives you two ways to access environment variables in Python: os.environ (a dictionary) and os.getenv() (a function). They're not interchangeable, and picking the right one matters.

Use os.environ['KEY'] for required variables:

import os

# This will raise KeyError if DATABASE_URL is not set
database_url = os.environ['DATABASE_URL']

If DATABASE_URL doesn't exist, your app crashes immediately with a clear error message. That's exactly what you want for critical configuration. Fail fast, fail loud.

Use os.getenv('KEY', 'default') for optional variables:

import os

# Returns None if LOG_LEVEL is not set
log_level = os.getenv('LOG_LEVEL')

# Returns 'info' if LOG_LEVEL is not set
log_level = os.getenv('LOG_LEVEL', 'info')

The os.getenv() function returns None by default or whatever fallback value you provide. Perfect for non-critical settings where you have sensible defaults.

Here's a practical example combining both approaches:

import os

# Required - app crashes without these
DATABASE_URL = os.environ['DATABASE_URL']
SECRET_KEY = os.environ['SECRET_KEY']

# Optional - has defaults
DEBUG = os.getenv('DEBUG', 'false')
MAX_CONNECTIONS = os.getenv('MAX_CONNECTIONS', '100')
LOG_LEVEL = os.getenv('LOG_LEVEL', 'info')

One gotcha: os.environ is a dictionary, but it's special. You can read and write to it, but those changes only affect the current Python process and any subprocesses it spawns. They don't modify your shell's environment.

Setting and Deleting Variables at Runtime

You can modify environment variables directly from Python code using dictionary operations:

import os

# Set a new environment variable
os.environ['API_TIMEOUT'] = '30'

# Update an existing one
os.environ['DEBUG'] = 'true'

# Delete a variable
del os.environ['LOG_LEVEL']

# Or use pop() with a default to avoid KeyError
timeout = os.environ.pop('API_TIMEOUT', '60')

This is useful for testing, dynamic configuration, or setting variables before spawning subprocess calls. But remember: these changes only exist in the current process. Once your Python script exits, they're gone.

Here's a common testing pattern:

import os
import unittest

class TestDatabaseConnection(unittest.TestCase):
    def setUp(self):
        # Save original value
        self.original_db = os.environ.get('DATABASE_URL')
        # Set test database
        os.environ['DATABASE_URL'] = 'postgresql://localhost/test_db'

    def tearDown(self):
        # Restore original value
        if self.original_db:
            os.environ['DATABASE_URL'] = self.original_db
        else:
            del os.environ['DATABASE_URL']

    def test_connection(self):
        # Your test here using the test database
        pass

Type Conversion: Everything is a String

Environment variables in Python are always strings. Always. Even if you set PORT=5000 in your shell, os.environ['PORT'] returns the string '5000', not the integer 5000.

This trips up developers constantly:

import os

# WRONG - this is a string, not a number
port = os.getenv('PORT', '5000')
# server.listen(port)  # Error: expected int, got str

# RIGHT - convert to int
port = int(os.getenv('PORT', '5000'))

# WRONG - this is always truthy because it's a non-empty string
debug = os.getenv('DEBUG', 'false')
if debug:  # This runs even when DEBUG=false!
    print("Debug mode")

# RIGHT - explicitly check the string value
debug = os.getenv('DEBUG', 'false').lower() == 'true'
if debug:
    print("Debug mode")

Here's a type conversion helper that handles common cases:

import os
from typing import Optional, TypeVar, Callable

T = TypeVar('T')

def get_env(
    key: str,
    default: Optional[T] = None,
    converter: Callable[[str], T] = str
) -> T:
    """Get environment variable with type conversion."""
    value = os.getenv(key)
    if value is None:
        if default is None:
            raise ValueError(f"Required environment variable {key} is not set")
        return default
    return converter(value)

# Usage
PORT = get_env('PORT', default=5000, converter=int)
DEBUG = get_env('DEBUG', default=False, converter=lambda x: x.lower() == 'true')
API_KEYS = get_env('API_KEYS', default=[], converter=lambda x: x.split(','))
TIMEOUT = get_env('TIMEOUT', default=30.0, converter=float)

Common conversions you'll need:

  • Integers: int(os.getenv('PORT', '5000'))
  • Floats: float(os.getenv('RATE_LIMIT', '10.5'))
  • Booleans: os.getenv('DEBUG', 'false').lower() in ('true', '1', 'yes')
  • Lists: os.getenv('ALLOWED_HOSTS', '').split(',')
  • JSON: json.loads(os.getenv('CONFIG', '{}'))

Python-dotenv: Loading from .env Files

Managing environment variables through your shell or system settings works, but it's tedious for local development. The python-dotenv package solves this by loading variables from a .env file automatically.

Install python-dotenv:

pip install python-dotenv

Create a .env file in your project root:

DATABASE_URL=postgresql://localhost/myapp
SECRET_KEY=your-secret-key-here
DEBUG=true
PORT=5000
REDIS_URL=redis://localhost:6379

Load variables at the start of your application:

import os
from dotenv import load_dotenv

# Load .env file
load_dotenv()

# Now access variables normally
DATABASE_URL = os.environ['DATABASE_URL']
DEBUG = os.getenv('DEBUG', 'false').lower() == 'true'
PORT = int(os.getenv('PORT', '5000'))

The load_dotenv() function reads your .env file and sets those values in os.environ. By default, it won't override variables that are already set in your environment. This is useful because it means your production environment variables (set through your hosting provider) take precedence over your .env file.

Force override existing variables:

from dotenv import load_dotenv

# Override any existing environment variables
load_dotenv(override=True)

Use override=True carefully. It's useful for testing scenarios where you want your .env file to always win, but in production, you typically want actual environment variables to take priority.

Framework-specific auto-loading:

Some frameworks detect and load .env files automatically:

  • Flask: Use flask-dotenv or manually call load_dotenv() before creating your app
  • Django: As of Django 4.0+, you can use environ.Env() from django-environ
  • FastAPI: Call load_dotenv() in your main application file before importing config

Here's a typical FastAPI setup:

from dotenv import load_dotenv
from fastapi import FastAPI
import os

# Load .env before anything else
load_dotenv()

app = FastAPI()

DATABASE_URL = os.environ['DATABASE_URL']
SECRET_KEY = os.environ['SECRET_KEY']

@app.get("/")
def read_root():
    return {"debug": os.getenv('DEBUG', 'false')}

Loading Environment Variables from Specific Paths

Sometimes you need to load from a different location than the current directory. Maybe you have multiple .env files for different configurations, or your .env file lives in a parent directory.

Use pathlib to construct reliable paths:

from pathlib import Path
from dotenv import load_dotenv

# Load from the same directory as this Python file
env_path = Path(__file__).parent / '.env'
load_dotenv(dotenv_path=env_path)

# Load from a parent directory
env_path = Path(__file__).parent.parent / '.env'
load_dotenv(dotenv_path=env_path)

# Load environment-specific file
import sys
env = sys.argv[1] if len(sys.argv) > 1 else 'development'
env_path = Path(__file__).parent / f'.env.{env}'
load_dotenv(dotenv_path=env_path)

The last pattern lets you do things like:

python app.py production  # Loads .env.production
python app.py staging     # Loads .env.staging
python app.py             # Loads .env.development (default)

Your .env.production might have your production database, while .env.development points to your local database. Keep .env.production out of version control, but .env.development can be committed as a template.

Structured Configuration with Dataclasses

Once you have more than a handful of environment variables, accessing them through scattered os.environ calls gets messy. Create a config object that groups related settings and provides type safety:

import os
from dataclasses import dataclass
from dotenv import load_dotenv

load_dotenv()

@dataclass
class DatabaseConfig:
    url: str
    pool_size: int = 10
    timeout: float = 30.0

    @classmethod
    def from_env(cls):
        return cls(
            url=os.environ['DATABASE_URL'],
            pool_size=int(os.getenv('DB_POOL_SIZE', '10')),
            timeout=float(os.getenv('DB_TIMEOUT', '30.0'))
        )

@dataclass
class RedisConfig:
    url: str
    max_connections: int = 50

    @classmethod
    def from_env(cls):
        return cls(
            url=os.environ['REDIS_URL'],
            max_connections=int(os.getenv('REDIS_MAX_CONNECTIONS', '50'))
        )

@dataclass
class AppConfig:
    debug: bool
    secret_key: str
    port: int
    database: DatabaseConfig
    redis: RedisConfig

    @classmethod
    def from_env(cls):
        return cls(
            debug=os.getenv('DEBUG', 'false').lower() == 'true',
            secret_key=os.environ['SECRET_KEY'],
            port=int(os.getenv('PORT', '5000')),
            database=DatabaseConfig.from_env(),
            redis=RedisConfig.from_env()
        )

# Load once at module level
config = AppConfig.from_env()

# Use throughout your app
print(f"Starting server on port {config.port}")
print(f"Connecting to database: {config.database.url}")
if config.debug:
    print("DEBUG MODE ENABLED")

Now you have autocomplete, type checking, and a single place to modify when you add new environment variables. Your IDE will catch typos like config.databas.url before you run the code.

For larger applications, consider libraries like pydantic-settings:

from pydantic_settings import BaseSettings
from pydantic import Field

class Settings(BaseSettings):
    database_url: str
    secret_key: str
    debug: bool = False
    port: int = 5000
    redis_url: str = "redis://localhost:6379"

    class Config:
        env_file = '.env'
        env_file_encoding = 'utf-8'

settings = Settings()

Pydantic handles type conversion, validation, and even nested configuration automatically. It's worth the extra dependency for anything beyond a small script.

The .env.example Pattern

Your .env file should never be committed to version control. But how do new developers know what variables they need to set? Enter .env.example.

Create a template file showing all required variables without sensitive values:

.env.example:

# Database
DATABASE_URL=postgresql://username:password@localhost/dbname

# Redis
REDIS_URL=redis://localhost:6379

# Application
SECRET_KEY=generate-a-random-secret-key
DEBUG=true
PORT=5000

# External APIs
STRIPE_API_KEY=sk_test_your_test_key_here
SENDGRID_API_KEY=SG.your_key_here

# Optional: Feature flags
ENABLE_ANALYTICS=false

Commit .env.example to your repository. When someone clones your project, they copy .env.example to .env and fill in their actual values:

cp .env.example .env
# Edit .env with real credentials

Your .gitignore should include:

.env
.env.local
.env.*.local

But not .env.example. The example file is documentation, not configuration.

Good .env.example files include:

  • Comments explaining what each variable does
  • Example values (fake credentials, localhost URLs)
  • Indication of which variables are required vs optional
  • Links to where developers can obtain API keys

Security: Keeping Secrets Out of Code

Environment variables aren't perfectly secure, but they're dramatically better than hardcoding secrets. Follow these rules:

1. Always add .env to .gitignore

Do this immediately when starting a new project:

echo ".env" >> .gitignore
echo ".env.local" >> .gitignore

Already committed a .env file? Remove it from history:

git rm --cached .env
git commit -m "Remove .env from repository"

Leaked credentials should be rotated immediately. Removing them from Git history isn't enough.

2. Never log environment variable values

This seems obvious, but it's easy to mess up:

import os
import logging

# WRONG
logging.info(f"Using API key: {os.environ['API_KEY']}")

# WRONG
print(f"Config: {os.environ}")

# RIGHT
logging.info("API key configured successfully")

# RIGHT - redact sensitive values
def safe_env_dump():
    sensitive_keys = {'SECRET_KEY', 'API_KEY', 'DATABASE_URL', 'PASSWORD'}
    return {
        key: '***REDACTED***' if any(s in key.upper() for s in sensitive_keys) else value
        for key, value in os.environ.items()
    }

3. Don't include env vars in error messages

import os

# WRONG - exposes secret in traceback
def connect_database():
    url = os.environ['DATABASE_URL']
    if not validate_url(url):
        raise ValueError(f"Invalid database URL: {url}")

# RIGHT - generic error message
def connect_database():
    url = os.environ['DATABASE_URL']
    if not validate_url(url):
        raise ValueError("Invalid database URL format")

4. Use different secrets for each environment

Your development, staging, and production environments should have completely different credentials. If your dev database password is "password123", that's fine. If your production database password is "password123", that's a problem.

Generate strong random secrets with Python:

import secrets

# 32-byte hex string (64 characters)
secret_key = secrets.token_hex(32)
print(f"SECRET_KEY={secret_key}")

# URL-safe string
api_key = secrets.token_urlsafe(32)
print(f"API_KEY={api_key}")

5. Consider encryption for sensitive local development

If your .env file contains production-like data (even test accounts), encrypt it:

# Encrypt your .env file
gpg -c .env

# Commit .env.gpg (encrypted version)
git add .env.gpg

# Decrypt when needed
gpg .env.gpg

Or use tools like git-crypt or BlackBox for automatic encryption of sensitive files in Git repositories.

Working with Multiple Environments

Real applications run in multiple environments: local development, testing, staging, production. Each needs different configuration.

Strategy 1: Environment-specific .env files

.env.development
.env.test
.env.staging
.env.production

Load based on an environment variable:

import os
from pathlib import Path
from dotenv import load_dotenv

env = os.getenv('ENVIRONMENT', 'development')
env_file = Path(__file__).parent / f'.env.{env}'

load_dotenv(dotenv_path=env_file)

Set ENVIRONMENT in your deployment pipeline:

# Development (default)
python app.py

# Staging
ENVIRONMENT=staging python app.py

# Production
ENVIRONMENT=production python app.py

Strategy 2: Cascading .env files

Load a base .env file, then override with environment-specific values:

from pathlib import Path
from dotenv import load_dotenv
import os

# Load base configuration
base_env = Path(__file__).parent / '.env'
load_dotenv(dotenv_path=base_env)

# Override with environment-specific config
env = os.getenv('ENVIRONMENT', 'development')
env_file = Path(__file__).parent / f'.env.{env}'
load_dotenv(dotenv_path=env_file, override=True)

Your .env file contains defaults that work everywhere:

# .env (base config)
DEBUG=false
PORT=5000
LOG_LEVEL=info

Your .env.development overrides specific values:

# .env.development
DEBUG=true
LOG_LEVEL=debug
DATABASE_URL=postgresql://localhost/myapp_dev

Strategy 3: System environment variables in production

In production, don't use .env files at all. Set variables through your hosting platform (Heroku, AWS, Google Cloud, etc.) or orchestration tool (Kubernetes, Docker Compose).

Your code doesn't need to change:

import os

# Works whether variables come from .env or system environment
DATABASE_URL = os.environ['DATABASE_URL']

Most platforms provide a web UI or CLI for managing environment variables:

# Heroku
heroku config:set SECRET_KEY=your-secret-key

# AWS Elastic Beanstalk
eb setenv SECRET_KEY=your-secret-key

# Google Cloud Run
gcloud run services update myapp --set-env-vars SECRET_KEY=your-secret-key

This keeps secrets out of your codebase entirely. Your repository contains zero sensitive information.

For teams managing Python projects across multiple environments, EnvManager keeps environment variables organized and synced across development, staging, and production, with integrations for Vercel, Railway, and Render (free tier available). Instead of passing .env files through Slack or maintaining separate config per environment, you define variables once and team members pull the latest configuration.

Try EnvManager free →

Common Patterns and Anti-Patterns

Good: Validate critical env vars at startup

import os
import sys
from dotenv import load_dotenv

load_dotenv()

# Check required variables immediately
required = ['DATABASE_URL', 'SECRET_KEY', 'REDIS_URL']
missing = [var for var in required if var not in os.environ]

if missing:
    print(f"Error: Missing required environment variables: {', '.join(missing)}")
    print("Please check your .env file or environment configuration")
    sys.exit(1)

print("All required environment variables are set")

Bad: Silent failures with bad defaults

# This runs even if DATABASE_URL is wrong/missing
DATABASE_URL = os.getenv('DATABASE_URL', 'postgresql://localhost/default')

If DATABASE_URL isn't set, you probably want your app to crash, not silently connect to some default database.

Good: Separate secrets from non-secrets

# Non-sensitive config can have defaults
LOG_LEVEL = os.getenv('LOG_LEVEL', 'info')
MAX_WORKERS = int(os.getenv('MAX_WORKERS', '4'))

# Secrets should be required
SECRET_KEY = os.environ['SECRET_KEY']
DATABASE_URL = os.environ['DATABASE_URL']

Bad: Every variable is optional

# Everything has a default, even secrets
SECRET_KEY = os.getenv('SECRET_KEY', 'default-secret')
API_KEY = os.getenv('API_KEY', 'test-key')

This leads to bugs where you think your production app is using real credentials, but it's actually using the defaults.

Good: Type conversion with error handling

try:
    PORT = int(os.getenv('PORT', '5000'))
except ValueError:
    print("Error: PORT must be a valid integer")
    sys.exit(1)

Bad: Type conversion without validation

# Crashes with unhelpful error if PORT=abc
PORT = int(os.environ['PORT'])

Debugging Environment Variable Issues

When environment variables aren't working, try these debugging steps:

1. Print all environment variables:

import os
import pprint

print("All environment variables:")
pprint.pprint(dict(os.environ))

2. Check if a specific variable is set:

import os

var_name = 'DATABASE_URL'
if var_name in os.environ:
    print(f"{var_name} is set")
    print(f"Value: {os.environ[var_name]}")
else:
    print(f"{var_name} is NOT set")

3. Verify .env file is being loaded:

from pathlib import Path
from dotenv import load_dotenv

env_path = Path(__file__).parent / '.env'
print(f"Looking for .env at: {env_path}")
print(f".env exists: {env_path.exists()}")

loaded = load_dotenv(dotenv_path=env_path, verbose=True)
print(f".env loaded successfully: {loaded}")

The verbose=True flag makes load_dotenv() print what it's doing.

4. Check for whitespace issues:

import os

# .env might have spaces: DATABASE_URL = postgres://...
url = os.getenv('DATABASE_URL')
print(f"Raw value: '{url}'")
print(f"Stripped value: '{url.strip()}'")

5. Test variable precedence:

import os
from dotenv import load_dotenv

# Set in code
os.environ['TEST_VAR'] = 'from_code'

# Try to load from .env (won't override by default)
load_dotenv()

print(f"TEST_VAR = {os.environ['TEST_VAR']}")  # Still 'from_code'

# Force override
load_dotenv(override=True)
print(f"TEST_VAR = {os.environ['TEST_VAR']}")  # Now from .env

Now go remove those hardcoded credentials from your Python code. Your future self will thank you.

Ready to manage your environment variables securely?

EnvManager helps teams share secrets safely, sync configurations across platforms, and maintain audit trails.

Get started for free

Get DevOps tips in your inbox

Weekly security tips, environment management best practices, and product updates.

No spam. Unsubscribe anytime.