← All posts

Python Environment Variables: How to Get, Set and Load Them

Published

Environment variables are how a Python program gets configuration from outside itself: database URLs, API keys, the port to run on, whether debug mode is on. This post covers reading them, setting them, loading them from a .env file, and the handful of things that catch people out.

Reading environment variables

Everything lives in os.environ, which behaves like a dictionary:

import os

database_url = os.environ["DATABASE_URL"]

If DATABASE_URL is not set, that line raises a KeyError. That is usually what you want for something the app cannot run without, because it fails loudly at startup rather than mysteriously later.

For optional values, use os.getenv(), which returns None if the variable is missing, or a default if you give one:

port = os.getenv("PORT", "8000")
log_level = os.getenv("LOG_LEVEL")  # None if unset

The rule of thumb: os.environ[...] for things that must exist, os.getenv(...) with a default for things that are genuinely optional.

The string trap

Every environment variable is a string. Always. This is the mistake that costs the most debugging time:

debug = os.getenv("DEBUG", "false")

if debug:
    print("This runs even when DEBUG=false")

The string "false" is truthy in Python because it is non empty. You have to convert:

debug = os.getenv("DEBUG", "false").lower() in ("1", "true", "yes")
max_connections = int(os.getenv("MAX_CONNECTIONS", "10"))

Same for numbers. os.getenv("PORT") gives you "8000", not 8000, and passing that to something expecting an integer will fail in a way that looks unrelated.

Setting environment variables in Python

You can write to os.environ directly:

os.environ["API_MODE"] = "test"

Two things to know. The value must be a string, so os.environ["RETRIES"] = 3 raises a TypeError. And the change only affects the current process and any child processes it starts. It does not persist after the script exits and it does not affect your shell.

setdefault is useful when you want to set a value only if nothing else has already:

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")

Django's own manage.py does exactly this.

To pass a different environment to a subprocess without changing your own:

import subprocess

subprocess.run(
    ["python", "worker.py"],
    env={**os.environ, "WORKER_ID": "3"},
)

Spreading os.environ first matters. If you pass only {"WORKER_ID": "3"}, the child gets no PATH, no HOME, nothing.

Setting them from the shell

For a single run:

DATABASE_URL=postgres://localhost/dev python app.py

For the rest of the terminal session:

export DATABASE_URL=postgres://localhost/dev
python app.py

On Windows PowerShell:

$env:DATABASE_URL = "postgres://localhost/dev"
python app.py

All of these vanish when the terminal closes. Permanent variables go in your shell profile (~/.zshrc, ~/.bashrc) or, on Windows, in System Properties. But for project specific configuration, a .env file is the better approach.

Loading from a .env file

A .env file is a plain text file of KEY=VALUE lines in your project root. It keeps configuration out of code and out of your shell profile, and it works the same on every operating system. If you are new to the format, what is a .env file covers it in detail.

Python does not read .env files natively. The standard library is python-dotenv:

pip install python-dotenv
from dotenv import load_dotenv
import os

load_dotenv()

database_url = os.environ["DATABASE_URL"]

load_dotenv() looks for a .env file starting in the current directory and walking up, then puts each line into os.environ. Call it before you read anything.

By default it will not overwrite a variable that is already set. That is deliberate: if your deployment platform sets DATABASE_URL, the .env file will not stomp on it. To force the file to win, pass override=True.

You can point it at a specific file:

load_dotenv(".env.local")

And you can load the values into a dictionary without touching os.environ at all, which is handy in tests:

from dotenv import dotenv_values

config = dotenv_values(".env")

Add .env to .gitignore before your first commit. Committing it is the single most common way API keys end up public.

Validating configuration in one place

Scattering os.getenv calls through a codebase means you find out about a missing variable whenever that line happens to run. A settings object that reads everything at startup is better:

import os

class Settings:
    def __init__(self):
        self.database_url = os.environ["DATABASE_URL"]
        self.stripe_key = os.environ["STRIPE_SECRET_KEY"]
        self.debug = os.getenv("DEBUG", "false").lower() == "true"
        self.port = int(os.getenv("PORT", "8000"))

settings = Settings()

Now a missing variable fails on import, with a clear KeyError naming what is missing, and the type conversion happens once.

If you want this with less boilerplate, pydantic-settings does the same thing with validation and .env loading built in:

pip install pydantic-settings
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    stripe_secret_key: str
    debug: bool = False
    port: int = 8000

    model_config = {"env_file": ".env"}

settings = Settings()

Types are converted for you. DEBUG=true becomes True, PORT=8000 becomes 8000, and a missing required field raises a validation error listing every problem at once. For anything beyond a script, this is the approach to use.

Framework notes

Django reads DJANGO_SETTINGS_MODULE and expects you to pull everything else in settings.py. Most projects call load_dotenv() at the top of that file or use django-environ.

Flask reads FLASK_APP and FLASK_ENV, and the flask command loads .env and .flaskenv automatically if python-dotenv is installed. Running python app.py directly does not.

FastAPI has no built in loading. pydantic-settings is the conventional choice and it is made by the same people.

Production

Do not load a .env file in production. The platform you deploy to (Railway, Heroku, Render, AWS, Kubernetes, whatever it is) has a way to set environment variables directly, and that is where they should come from. The .env file is for your machine. If load_dotenv() finds nothing in production, it quietly does nothing, which is the correct behaviour.

Sharing with a team

Everything above works for one person. Once two developers need the same .env, someone pastes it into Slack, and now your database password lives in a chat log. When a key rotates, whoever was not in the thread is running against stale credentials.

That is the point at which a secrets manager makes sense: one shared source, each developer pulls it down with a command, rotation propagates. We build one called Krypt with a flat price for the whole team, and there is a comparison of the options if you want to weigh it against Doppler, Infisical or Vault. If you are working alone, you do not need any of them yet.

FAQ

What is the difference between os.environ and os.getenv? os.environ["KEY"] raises KeyError if the variable is missing. os.getenv("KEY") returns None, or a default you supply. Use the first for required values so the app fails fast.

Why is my boolean environment variable always True? Because it is a string. "false" is a non empty string and therefore truthy. Compare it to "true" explicitly, or use pydantic-settings which converts types.

Does os.environ change persist after the script ends? No. It affects the current process and its children only. To persist a variable, set it in your shell profile or your deployment platform.

Why does load_dotenv not override my existing variable? By default it leaves already set variables alone so platform configuration wins over the file. Pass override=True to change that.

Can I use environment variables in a Python virtual environment? Yes, they are unrelated to virtual environments. A venv isolates packages, not the process environment. Set variables the same way regardless.