Search Topics

Search across all FastAPI topics

GitHub

Environment Variables

Fundamentals

Your database password is in your source code. Someone just pushed it to GitHub. You have about 30 minutes before things get bad.

You push your code to GitHub. Within 30 minutes, someone has scraped your DATABASE_URL from the commit history and is running queries against your production database.

terminal
[SECURITY ALERT] GitHub detected a potential secret in your repository.
Commit: a1b2c3d — "Add database config"
File: main.py — Line 5: DATABASE_URL = "postgresql://admin:p@ssw0rd@prod-db.example.com:5432/myapp"

Question

How does this happen? You write a database URL in your code because you need it to work. You push to GitHub because you need to deploy. And now the entire internet can see your production password. The fix isn't "be more careful" — it's to keep secrets out of your code entirely.

The Flow: Secrets Stay Outside Code

Here's the key idea: your code reads configuration from the environment, never from hardcoded values. This way, secrets live in a file that never gets committed.

.env file

Secrets live here (gitignored)

load_dotenv() or pydantic-settings

Reads the file

Environment variables

Available to your app

Your code

Uses settings.database_url

Why env vars matter

What you just learned

Secrets in source code get exposed when you push to GitHub

Environment variables keep configuration outside your codebase

.env files store secrets locally — and get gitignored so they're never committed

Why Not Just Hardcode It?

Beyond security, there's a practical reason: different environments need different values. Your local database URL isn't the same as staging or production. Hardcoding means changing code every time you deploy somewhere new.

Environment variables let you change configuration without changing code. Same codebase, different settings per environment.

The .env File

A .env file stores your configuration as simple key-value pairs. It lives in your project root and should never be committed to version control.

.env
DATABASE_URL=postgresql://user:password@localhost/mydb
SECRET_KEY=super-secret-key-change-me
DEBUG=true
API_VERSION=v1

Watch out

Add .env to your .gitignore before your first commit. If you add it after, the file is already in your git history — and removing it from history is a pain.

Loading with python-dotenv

The simplest approach: python-dotenv reads your .env file and makes the values available through os.getenv(). It works, but there's no type validation — everything comes back as a string.

config.py
from dotenv import load_dotenv
import os

load_dotenv()  # Load .env file into environment

DATABASE_URL = os.getenv("DATABASE_URL")
SECRET_KEY = os.getenv("SECRET_KEY", "fallback-key")
# Careful — this is a string, not a boolean!
DEBUG = os.getenv("DEBUG", "false").lower() == "true"

Pydantic Settings (The Better Way)

For production apps, pydantic-settings is the move. It reads your .env file, validates types automatically, provides defaults, and gives you a clean settings object with IDE autocomplete.

config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str          # Required — app crashes if missing
    secret_key: str            # Required — no forgetting this one
    debug: bool = False        # Optional with default — auto-parsed!
    api_version: str = "v1"    # Optional with default

    class Config:
        env_file = ".env"

settings = Settings()
# settings.database_url → reads DATABASE_URL from .env
# settings.debug → True (auto-converted from string "true")
# Typo in your env var name? Pydantic catches it at startup.

Loading config

What you just learned

python-dotenv is simple but everything is a string — you parse types yourself

pydantic-settings validates types, provides defaults, and catches missing vars at startup

Both read from .env files — pydantic-settings just does more for you

The sneaky override trap

You set DATABASE_URL in both your .env file and a Docker environment variable. Your app uses the wrong one and you spend an hour debugging.

Broken code
config.py
# .env file
DATABASE_URL=postgresql://user:pass@localhost/dev

# Docker compose
environment:
  - DATABASE_URL=postgresql://user:pass@prod-db/prod

# config.py
from dotenv import load_dotenv
load_dotenv()  # Which value wins?

Try It: Config Validator

Edit the .env values and watch Pydantic Settings validate them in real-time.

Think about it...

If you set DATABASE_URL in both your .env file and your shell environment, which one does python-dotenv use by default?

Hint: Think about what 'override' means as a parameter...

Key Points

Never Commit Secrets

Passwords, API keys, and tokens should never appear in your source code

.env for Local Config

Store environment-specific values in a .env file at project root

pydantic-settings for Type Safety

Get automatic validation, defaults, and autocomplete for your config

.gitignore Your .env

Always add .env to .gitignore to prevent accidental commits

These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.

1
Hardcoding secrets in source code
Putting passwords and API keys directly in Python files
Don't do this
config.py
from fastapi import FastAPI

app = FastAPI()

DATABASE_URL = "postgresql://admin:p@ssw0rd@db.example.com/prod"
SECRET_KEY = "my-super-secret-jwt-key"
# These will end up on GitHub!
Secrets in source code get committed to version control and become visible to anyone with repo access. Use .env files and pydantic-settings to keep secrets separate from code.
2
Forgetting to .gitignore the .env file
Committing .env to version control by accident
Don't do this
.gitignore
# .gitignore
__pycache__/
*.pyc
# Oops — .env is not listed!
Even if you use .env files correctly, forgetting to add .env to .gitignore means your secrets get committed on the first git add. Always add .env to .gitignore before your first commit.