Skip to content

Environment Variables (.env)

Environment variables are the standard way to configure apps.

Examples:

  • SECRET_KEY
  • DATABASE_URL
  • MAIL_USERNAME
  • MAIL_PASSWORD
  • keeps secrets out of code
  • same container/code can run in dev/staging/prod

In local development, you can use a .env file.

Common library:

  • python-dotenv

Install:

bash
pip install python-dotenv

Then Flask can load .env automatically when using flask run (depending on setup), or you can load manually.

Add .env to .gitignore.

Instead commit:

  • .env.example

So people know what variables are required.

python
import os
secret = os.environ.get("SECRET_KEY")

Always define safe defaults for development, but never for production secrets.

What load_dotenv() does, and what it refuses to do

Section titled “What load_dotenv() does, and what it refuses to do”
diagram Diagram mermaid

Measured with APP_NAME already present in the real environment:

precedence.py
os.environ["APP_NAME"] = "already-in-real-env"
 
load_dotenv()
os.environ["APP_NAME"]        # 'already-in-real-env'   <- the file did NOT win
os.environ["SECRET_KEY"]      # 'dev-secret'            <- set, because it was absent
 
load_dotenv(override=True)
os.environ["APP_NAME"]        # 'from-dotenv'

This is the behaviour you want: .env supplies local defaults, and a real environment variable — the kind your host injects — always beats the file. It also means a stale export in your shell can quietly shadow the file, which is the first thing to check when a value looks wrong.

types.py
os.environ["MAX"]          # '25'    (str, never int)
int(os.environ["MAX"]) + 1 # 26
os.environ["EMPTY"]        # ''      <- empty string, not None

Which sets up the trap this page exists for:

.env linebool(value)
DEBUG=falseTrue
DEBUG=0True
DEBUG=noTrue
DEBUG=FalseTrue
DEBUG=False

Measured — every one of those is a non-empty string, and every non-empty string is truthy. if os.environ.get("DEBUG"): turns debug mode on when you wrote DEBUG=false.

.env
APP_NAME=from-dotenv
QUOTED="has spaces"
EXPANDED=${APP_NAME}-suffix
# a comment
EMPTY=
measured with dotenv_values()
QUOTED    'has spaces'          <- the quotes are stripped
EXPANDED  'from-dotenv-suffix'  <- ${VAR} expanded from earlier lines
comments and blank lines skipped
config.py
import os
from dotenv import load_dotenv
 
load_dotenv()                                  # local development only
 
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]        # required: fail loudly
app.config["MAX_ITEMS"] = int(os.environ.get("MAX_ITEMS", 20))
app.config["DEBUG"] = env_bool("FLASK_DEBUG")

os.environ[...] for anything the app cannot run without. A missing SECRET_KEY should stop startup, not produce a confusing failure on the first login.

sketch Which value wins, and is it true? p5.js
A real environment variable beats the .env file unless override is set. And every value is a string, so 'false' is truthy.
pch.quizTag pch.quizDefaultTitle
  1. A .env file contains DEBUG=false. What does if os.environ.get('DEBUG'): evaluate to?

    pch.quizShowAnswer

    B — True, because every non-empty string is truthy — Measured: 'false', '0', 'no' and 'False' are all truthy. This is a real route to enabling the interactive debugger in production. Parse the value explicitly instead.

  2. APP_NAME is already set in the real environment and also appears in .env. After load_dotenv(), which value is in os.environ?

    pch.quizShowAnswer

    B — the real environment value; .env only fills in names that are absent — That precedence is what makes .env safe for local defaults while a host's injected variables still win. load_dotenv(override=True) reverses it.

  3. Which belongs in version control?

    pch.quizShowAnswer

    B — .env.example listing the keys with no values — A committed .env is a leaked credential that stays in history after deletion. An example file tells a new contributor what to set without exposing anything.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading