Skip to main content

How to Handle Environment Variables in Python

Hardcoding secrets like API keys, passwords, or database URLs directly in your source code is a major security risk. Anyone with access to the repository can see those credentials, and accidentally committing them to version control can expose them permanently. Environment variables solve this by keeping sensitive configuration separate from your application logic.

This guide covers modern best practices for managing environment variables in Python, from the built-in os module to industry-standard tools like python-dotenv, along with techniques for validation, type casting, and organizing configuration cleanly.

Using the Built-in os Module

Python's os module provides direct access to system environment variables without any additional installation.

Accessing Variables

You have two main options for retrieving environment variables, each suited to a different situation:

import os

# Raises KeyError if the variable is missing
# database_url = os.environ["DATABASE_URL"]

# Returns None if the variable is missing
debug_mode = os.getenv("DEBUG")

# Returns a default value if the variable is missing
port = os.getenv("PORT", "8080")

# print(f"Database: {database_url}")
print(f"Debug: {debug_mode}")
print(f"Port: {port}")
Choosing the Right Method
  • Use os.environ["KEY"] when the variable must exist. A missing value crashes the application immediately with a KeyError, making configuration errors obvious during startup.
  • Use os.getenv("KEY") for optional configuration where a sensible default exists or where None is an acceptable value.

A Common Mistake: Assuming Environment Variables Have Types

A frequent source of bugs is forgetting that environment variables are always strings, regardless of what they look like:

import os

os.environ["DEBUG"] = "True"
os.environ["PORT"] = "8080"

# Wrong: comparing a string to a boolean
if os.getenv("DEBUG") == True: # This is always False!
print("Debug mode on")

# Wrong: using a string as a number
port = os.getenv("PORT")
print(port + 1) # TypeError: can only concatenate str to str

Output:

TypeError: can only concatenate str (not "int") to str

Always cast environment variables to the appropriate type explicitly.

Type Casting

import os

# Integer conversion
port = int(os.getenv("PORT", "8080"))
max_connections = int(os.getenv("MAX_CONNECTIONS", "100"))

# Boolean conversion
debug = os.getenv("DEBUG", "False").lower() in ("true", "1", "yes")

# List conversion (comma-separated values)
allowed_hosts = os.getenv("ALLOWED_HOSTS", "localhost").split(",")

print(f"Port: {port} (type: {type(port).__name__})")
print(f"Debug: {debug} (type: {type(debug).__name__})")
print(f"Hosts: {allowed_hosts}")

Example output:

Port: 8080 (type: int)
Debug: False (type: bool)
Hosts: ['localhost']

Using python-dotenv for Local Development

Manually setting environment variables in the terminal for every session is tedious and error-prone. The python-dotenv package loads variables from a .env file automatically, simulating a production-like environment on your local machine.

Installation

pip install python-dotenv

Creating the Environment File

Create a file named .env in your project root:

.env
# .env
DATABASE_URL=postgres://user:pass@localhost:5432/myapp
SECRET_KEY=super-secret-key-123
DEBUG=True
API_TIMEOUT=30
Security First

Add .env to your .gitignore immediately after creating it. Never commit files containing real secrets to version control.

# .gitignore
.env

Loading Variables

Call load_dotenv() at the start of your application. After this call, all variables from the .env file are accessible through os.getenv() and os.environ:

import os
from dotenv import load_dotenv

load_dotenv()

database_url = os.getenv("DATABASE_URL")
secret_key = os.getenv("SECRET_KEY")

if not secret_key:
raise ValueError("SECRET_KEY environment variable is required")

print(f"Connecting to: {database_url}")
print(f"Secret key loaded: {'Yes' if secret_key else 'No'}")

Example output:

Connecting to: postgres://user:pass@localhost:5432/myapp
Secret key loaded: Yes

Loading from Custom Paths

You can specify a different file path or override existing system variables when needed:

from dotenv import load_dotenv
from pathlib import Path

# Load from a specific file
env_path = Path(".") / "config" / ".env.development"
load_dotenv(dotenv_path=env_path)

# Override existing system variables with values from the file
load_dotenv(override=True)

By default, load_dotenv() does not override variables that are already set in the system environment. Pass override=True when the .env file should take precedence.

Creating a Configuration Module

Centralizing all configuration access in a dedicated module keeps your codebase clean and makes it easy to see every setting your application depends on:

config.py
# config.py
import os
from dotenv import load_dotenv

load_dotenv()


class Config:
"""Application configuration from environment variables."""

DATABASE_URL: str = os.environ["DATABASE_URL"]
SECRET_KEY: str = os.environ["SECRET_KEY"]
DEBUG: bool = os.getenv("DEBUG", "False").lower() in ("true", "1", "yes")
PORT: int = int(os.getenv("PORT", "8080"))
API_TIMEOUT: int = int(os.getenv("API_TIMEOUT", "30"))


config = Config()

Then use it throughout your application:

main.py
# main.py
from config import config

def start_server():
print(f"Starting server on port {config.PORT}")
print(f"Debug mode: {config.DEBUG}")
print(f"API timeout: {config.API_TIMEOUT}s")

start_server()

Example output:

Starting server on port 8080
Debug mode: True
API timeout: 30s

Required variables use os.environ["KEY"], which raises a KeyError at import time if they are missing. Optional variables use os.getenv("KEY", "default") with sensible fallbacks. This means configuration errors are caught immediately when the module loads, not later during runtime.

Providing an Example File

Create a .env.example file with placeholder values and commit it to version control. This documents which variables your application expects without exposing any real secrets:

# .env.example
# Copy this file to .env and fill in your actual values

DATABASE_URL=postgres://user:password@localhost:5432/dbname
SECRET_KEY=your-secret-key-here
DEBUG=False
PORT=8080
API_TIMEOUT=30

New developers can copy this file to .env and fill in their own values to get started quickly.

Validating Required Variables at Startup

Catching missing configuration early prevents confusing errors later in the application lifecycle:

import os
from dotenv import load_dotenv

load_dotenv()

REQUIRED_VARS = ["DATABASE_URL", "SECRET_KEY", "API_KEY"]


def validate_environment():
"""Ensure all required environment variables are set."""
missing = [var for var in REQUIRED_VARS if not os.getenv(var)]

if missing:
raise EnvironmentError(
f"Missing required environment variables: {', '.join(missing)}"
)

print("All required environment variables are set")


validate_environment()

Example output (when variables are missing):

EnvironmentError: Missing required environment variables: API_KEY

Running this check at startup ensures the application fails fast with a clear error message instead of crashing unpredictably when a missing variable is first accessed deep in the code.

Best Practices Summary

PracticeDescription
Never commit secretsAdd .env to .gitignore before the first commit
Use uppercase namesFollow the UPPER_CASE_WITH_UNDERSCORES convention
Provide examplesCommit a .env.example file with placeholder values
Validate earlyCheck all required variables at startup
Cast types explicitlyEnvironment variables are always strings
Centralize accessUse a configuration module or class

Quick Reference

MethodBehavior When MissingUse Case
os.environ["KEY"]Raises KeyErrorRequired configuration
os.getenv("KEY")Returns NoneOptional configuration
os.getenv("KEY", "default")Returns the default valueConfiguration with fallback
load_dotenv()Loads .env into the environmentLocal development

Conclusion

Environment variables provide a secure and flexible way to manage application configuration.

  • Use os.environ["KEY"] for required values that should cause the application to fail immediately if missing
  • Use os.getenv("KEY", "default") for optional settings with sensible defaults.
  • The python-dotenv package simplifies local development by loading variables from a .env file without changing your system environment.

Always validate configuration at startup, cast types explicitly, and never commit secrets to version control.