How to Work with Environment Variables in Python: A Complete Guide

In this tutorial, you’ll learn how to work with environment variables in Python. Environment variables are key-value pairs that live outside your code, in your operating system or container environment. They store configuration data, secrets, API keys, database URLs, and anything else you don’t want hardcoded into your scripts. If you’ve been wondering how to read, set, and manage these variables from Python, this guide covers everything you need.

What Is an Environment Variable?

Imagine you run a web app. You have a database password that changes between your local machine and your production server. You could write the password directly into your code, but that means editing code every time you deploy. Environment variables solve this problem. Your operating system holds these values in its environment, and your Python code reads them at runtime. The same code works everywhere; only the environment changes.

Your system maintains hundreds of environment variables already. When you open a terminal and type echo $HOME, you are reading an environment variable. When you run Python and print os.environ, you see everything currently set. Now let’s see how Python interacts with this system.

Reading Environment Variables in Python

The standard library gives you two ways to read environment variables: os.environ and os.getenv(). Both live in the os module, and neither requires any external packages.

Using os.environ

The os.environ object is a dictionary-like structure. You can read values using bracket notation the same way you read a dictionary.

import os

database_url = os.environ['DATABASE_URL']
print(database_url)

If the variable does not exist, this raises a KeyError. That behavior is intentional. It forces you to handle missing variables explicitly rather than silently proceeding with a broken setup. When you want a default value instead of a crash, use the second form.

Using os.getenv()

The os.getenv() function takes a key and an optional default value. If the variable is not set, it returns the default instead of raising an exception.

import os

debug_mode = os.getenv('DEBUG', 'False')
print(debug_mode)  # prints: False (if DEBUG is not set)

This is the safer approach for configuration flags and optional settings. You always get a usable value, and you decide what the fallback should be.

Checking If a Variable Exists

Sometimes you want to know whether a variable is set before you try to use it. The in operator works with os.environ just like it works with a regular dictionary.

import os

if 'API_KEY' in os.environ:
    print('API key is set:', os.environ['API_KEY'])
else:
    print('API key not found in environment')

This pattern is useful for optional features that require external services. Your program can start without the API key but activate extra functionality when it is present.

Setting and Modifying Environment Variables

Setting an environment variable from within Python is as straightforward as assigning to a dictionary key. The change affects the current process and any child processes spawned afterward. It does not persist after your script ends unless you export it in your shell profile.

import os

os.environ['MY_APP_ENV'] = 'production'
print(os.getenv('MY_APP_ENV'))  # production

You can set multiple variables in a loop, which is common when loading configuration from a file or environment.

import os

config = {
    'DATABASE_HOST': 'localhost',
    'DATABASE_PORT': '5432',
    'DATABASE_NAME': 'myapp'
}

for key, value in config.items():
    os.environ[key] = value

print(os.environ['DATABASE_HOST'])  # localhost

Child processes inherit the environment of the parent. When you call subprocess.run() or any similar function, the child sees every variable that was set before the call. This is how configuration flows through your system.

The os.environb Object for Bytes

Python also provides os.environb, which is the bytes-level equivalent of os.environ. Keys and values are stored as bytes objects instead of strings. You need this when working with environment variables that contain non-ASCII data or when interfacing with low-level system APIs.

import os

# Setting a bytes environment variable
os.environb[b'APP_PATH'] = b'/usr/local/myapp'

# Reading it back
path = os.environb[b'APP_PATH']
print(path)  # b'/usr/local/myapp'

In practice, most developers stick with os.environ and decode bytes when needed. Use environb only when the bytes representation matters for your use case.

Python-dotenv: Loading Variables from .env Files

In development, you rarely want to set environment variables by hand every time you run a script. The python-dotenv library solves this. It reads a file named .env in your project root and loads every key-value pair as environment variables. This file is not committed to version control when it contains secrets, which makes it the standard approach for local development.

# .env file
# DATABASE_URL=postgresql://user:password@localhost:5432/mydb
# SECRET_KEY=my-super-secret-key-12345
# DEBUG=True

Install the library first.

pip install python-dotenv

Then load the variables at the top of your script, before you read any configuration.

from dotenv import load_dotenv
import os

# Load variables from .env file
load_dotenv()

# Now read them like normal environment variables
database_url = os.getenv('DATABASE_URL')
secret_key = os.getenv('SECRET_KEY')
debug = os.getenv('DEBUG', 'False')

print('Database:', database_url)
print('Secret loaded:', secret_key is not None)
print('Debug mode:', debug)

The load_dotenv() call reads the file and sets variables in os.environ. After that point, your code does not need to know whether the value came from a .env file or from the system environment. The abstraction is seamless.

Specifying a Custom Path

By default, load_dotenv() looks for .env in the current working directory. You can pass a custom path if your file lives somewhere else.

from dotenv import load_dotenv

# Load from a specific path
load_dotenv('/path/to/custom/env/file')

You can also use find_dotenv() to search upward from the current file for the nearest .env file. This is helpful when you want to find the project root automatically.

Overriding Existing Variables

Sometimes you want a .env file to override system-level variables. By default, load_dotenv() does not override variables that already exist. Use override=True to change that behavior.

from dotenv import load_dotenv

load_dotenv(override=True)  # .env values win over existing environment variables

Managing Secrets in Python Applications

Environment variables are the standard vehicle for passing secrets into Python applications. API keys, database passwords, encryption tokens, and service credentials should never appear in your source code. Instead, your application reads them from the environment at startup. This keeps secrets out of version control and allows different deployment environments to use different credentials without changing code.

Retrieving API Keys

Most APIs require an API key that identifies your application. Here is how you structure your code to handle one safely.

import os
import requests

api_key = os.getenv('OPENAI_API_KEY')
if not api_key:
    raise ValueError('OPENAI_API_KEY environment variable is not set')

headers = {'Authorization': f'Bearer {api_key}'}
response = requests.get('https://api.openai.com/v1/models', headers=headers)
print(response.json())

The check for None happens at startup. If the variable is missing, the application fails fast with a clear error message instead of making a failed API call that wastes resources.

Using os.getenv() with Type Conversion

Environment variables are always strings. When you need an integer, boolean, or float, you convert the string yourself. This conversion step is where bugs hide, because a missing variable or a malformed value can crash your app if you are not careful.

import os

def get_port():
    port = os.getenv('PORT', '8000')
    try:
        return int(port)
    except ValueError:
        raise ValueError(f'PORT must be an integer, got: {port}')

def get_debug_mode():
    value = os.getenv('DEBUG', 'False').strip().lower()
    return value in ('true', '1', 'yes', 'on')

print('Port:', get_port())
print('Debug:', get_debug_mode())

Wrapping your type conversions in small helper functions pays off. You write the error handling once, and every part of your application calls the same function. When you need to change how conversions work, you change one place.

os.path.expandvars(): Expanding Variables in Paths

The os.path.expandvars() function takes a string containing environment variable references and returns a string with those references replaced by their values. This is useful when you have path templates that include variable names.

import os

os.environ['USER_HOME'] = '/home/ninad'
template = '$USER_HOME/projects/myapp/config.yml'
expanded = os.path.expandvars(template)
print(expanded)  # /home/ninad/projects/myapp/config.yml

The function handles both $VAR and ${VAR} syntax. If a variable does not exist, it leaves the reference unchanged. This behavior is useful for templates that should work across different environments where some variables may be optional.

Environment Variables and Docker

When you run Python inside a Docker container, environment variables are the primary mechanism for configuring the application from outside. Docker lets you pass variables at runtime with the -e flag or through a Compose file.

# Running a container with an environment variable
# docker run -e DATABASE_URL=postgresql://user:pass@db:5432/mydb myimage
# docker-compose.yml
version: '3'
services:
  web:
    image: myimage
    environment:
      - DATABASE_URL=postgresql://user:pass@db:5432/mydb
      - DEBUG=False

Your Python code reads these variables exactly the same way it reads any other environment variable. The source changes, but the interface stays identical.

Passing a .env File to Docker

Docker Compose can load variables from a .env file using the env_file directive. This keeps your container configuration clean and lets you use the same development secrets locally without hardcoding them anywhere.

# docker-compose.yml
version: '3'
services:
  web:
    image: myimage
    env_file:
      - ./deploy/.env

The .env file in your project root is loaded automatically by python-dotenv, and the same file can be referenced by Docker Compose for container deployment. One source of truth, two use cases.

Environment Variables vs. Configuration Files

Both environment variables and configuration files store settings outside your code. The key difference is how they are accessed. Environment variables are read into memory when the process starts, and they work naturally with container orchestrators like Kubernetes and Docker Compose. Configuration files require parsing, which adds a dependency and a parsing step.

For simple key-value settings, environment variables are usually the better choice. They compose well with CI/CD pipelines and secrets managers. They are also easier to audit because every access goes through the same os.getenv() interface.

Configuration files become necessary when you have deeply nested structures, large schemas, or settings that need to be validated against a schema before the application starts. A JSON or YAML config file can enforce structure in ways that environment variables cannot. Many applications use both: environment variables for deployment-specific values, and config files for application-level defaults.

Common Mistakes and How to Avoid Them

The most frequent error with environment variables is assuming they are always present. Production systems sometimes change, and a variable that exists in your local environment may not exist on a colleague’s machine or in a different deployment. Always provide defaults or explicit checks for required variables.

Defaulting to Empty Strings

Using an empty string as a default is almost always wrong. It masks the fact that a variable is missing, and your application proceeds with a broken configuration silently.

# Bad approach
api_key = os.getenv('API_KEY', '')  # Empty string if missing

# Better approach
api_key = os.getenv('API_KEY')
if not api_key:
    raise ValueError('API_KEY is required but not set')

Distinguishing between a missing variable and a variable set to an empty string matters. The second case may be intentional in some deployment scenarios, while the first almost always indicates a configuration error.

Modifying os.environ After Importing

Once a module has imported a value from os.environ, changing the environment does not update that imported value. Python evaluates the import at the moment it happens, and the resulting variable holds the original value. To see updated values, re-read from os.environ or os.getenv().

import os

# Read once at module level
API_KEY = os.getenv('API_KEY')

def call_api():
    # API_KEY still holds the value from import time
    # Even if os.environ['API_KEY'] changed in the meantime
    print(API_KEY)

# Better: read inside the function when you need fresh values
def call_api_fresh():
    key = os.getenv('API_KEY')
    print(key)

This matters in long-running applications like web servers where configuration might be reloaded at runtime. Call os.getenv() inside the function that needs the value, not at module scope.

Not Using Quotes When Setting Variables in the Shell

When you set an environment variable in a bash script or the terminal, remember that spaces matter. export DATABASE_URL=postgresql://localhost/my app creates a variable with a value that includes trailing spaces or unexpected splitting. Always quote the value.

# Bash
export DATABASE_URL="postgresql://localhost/myapp"
export SECRET_KEY='my-secret-value'

The Python side is forgiving, but debugging a trailing space in an API key or database URL is frustrating and entirely preventable.

Using Environment Variables with Virtual Environments

Python virtual environments do not automatically isolate environment variables from the host system. A virtual environment changes sys.prefix and the PATH to point to the virtualenv’s Python installation, but environment variables pass through unchanged. This is usually the right behavior, but be aware of it when you are debugging variable-related issues.

import os
import sys

print('Virtual env prefix:', sys.prefix)
print('VIRTUAL_ENV set:', os.getenv('VIRTUAL_ENV'))
print('PATH contains venv:', os.getenv('PATH', '').split(':')[0])

If you need to isolate variables for different projects, consider using a tool like direnv which attaches specific environment configurations to specific directories.

Accessing System Environment Variables in Python

Python exposes all system environment variables through os.environ, including system-defined variables like PATH, HOME, USER, and LANG. You can read these the same way you read your own variables.

import os

print('Home directory:', os.environ.get('HOME'))
print('User:', os.environ.get('USER'))
print('Shell:', os.environ.get('SHELL'))

# PATH is a colon-separated string on Unix
path_entries = os.environ.get('PATH', '').split(':')
for entry in path_entries[:5]:
    print('Path entry:', entry)

Reading system variables is useful for discovering installed tools, constructing dynamic paths, and writing scripts that adapt to the host environment.

Deleting Environment Variables

You can remove an environment variable using the del keyword or the pop() method on os.environ. The deletion only affects the current process and its children.

import os

# Set a variable
os.environ['TEMP_VAR'] = 'hello'

# Remove it
del os.environ['TEMP_VAR']

# Or use pop with a default
removed_value = os.environ.pop('NONEXISTENT_VAR', 'default_value')
print('Removed or default:', removed_value)

Unsetting a variable is uncommon in normal application code, but it happens in test suites and in credential-refresh logic where a process needs to clear old values before setting new ones.

Best Practices for Using Environment Variables

After years of working with Python applications in various environments, here are the practices that consistently produce maintainable code.

Document every environment variable your application reads. A comment block at the top of your configuration module or a section in your README makes it easy for new developers to understand what needs to be set and why. The cost is minimal, and the benefit shows up every time someone new joins the project.

Validate environment variables at startup, not deep in your business logic. Call all your type conversions, default value logic, and required-variable checks at the very beginning of your application. Fail fast if something is misconfigured. Nothing is more frustrating than a production error that traces back to a misspelled environment variable name.

Use a consistent naming convention. DATABASE_URL and DATABASE_HOST should follow the same pattern as API_KEY and API_SECRET. Keep everything uppercase with underscores. It makes variables easy to find and grep.

Keep secrets out of logs. When you print environment variables for debugging, be careful about printing values like API keys and database passwords. Use masking or truncation in log output. A logging statement that accidentally exposes a production API key is a serious security incident.

Use separate environments for development and production, but keep the same variable names across both. Your local .env file should define the same keys as your production secrets, just with different values. This consistency means your code never needs to check which environment it is running in to decide which variable to read.

FAQ: Common Questions About Environment Variables

Can environment variables be nested or hierarchical?

Environment variables are flat key-value pairs. They do not natively support nesting or hierarchies. For complex configuration, store a JSON or YAML string as the variable value and parse it inside your code. Alternatively, use a configuration file for structured data and environment variables for the file path.

Are environment variables secure?

Environment variables are visible to any process that can read /proc/{pid}/environ on Linux systems, and they appear in process listings. They are not encrypted. For secrets like API keys and database passwords, use a proper secrets manager like HashiCorp Vault, AWS Secrets Manager, or Kubernetes secrets. Environment variables are a transport mechanism, not a storage mechanism for sensitive data.

Can I use environment variables in pytest?

Yes. You can set environment variables in pytest using monkeypatch or by creating a pytest.ini that specifies environment variables. For testing across multiple tests, consider using the env fixture from the pytest-dotenv plugin.

# Using monkeypatch in a test
def test_database_connection(monkeypatch):
    monkeypatch.setenv('DATABASE_URL', 'postgresql://test:test@localhost/testdb')
    from myapp import db
    connection = db.connect()
    assert connection is not None

How do I see all environment variables in Python?

Print os.environ directly, or iterate over its items. For a filtered view, use a dictionary comprehension.

import os

# Print all
for key, value in os.environ.items():
    print(f'{key}={value}')

# Filtered view: only MYAPP_* variables
myapp_vars = {k: v for k, v in os.environ.items() if k.startswith('MYAPP_')}
for key, value in myapp_vars.items():
    print(f'{key}={value}')

What happens when I set an environment variable in Python and then call subprocess?

The child process inherits a copy of the parent’s environment. Changes you make to os.environ after spawning a child do not affect that child. Each subprocess gets its own snapshot at the moment of the fork() call.

Can environment variables be integers or booleans?

Environment variables are always strings at the operating system level. Python reads them as strings, and you convert them to integers, booleans, or any other type you need. Python-dotenv can handle type casting for booleans, but for anything custom, write a small helper function that centralizes the conversion logic.

Summary

Environment variables are a fundamental part of any Python application. They bridge the gap between your code and the system around it. You read them with os.getenv(), set them by assigning to os.environ, and use python-dotenv to load them from .env files during development. Always validate at startup, provide sensible defaults, and keep secrets out of your code and your logs. Once you build the habit of managing configuration through the environment, your applications become portable, testable, and easier to deploy across different platforms and clouds.

Ninad
Ninad

A Python and PHP developer turned writer out of passion. Over the last 6+ years, he has written for brands including DigitalOcean, DreamHost, Hostinger, and many others. When not working, you'll find him tinkering with open-source projects, vibe coding, or on a mountain trail, completely disconnected from tech.

Articles: 121