← Back to Course Index
Lesson 19 of 20

Environment & Configuration

~15 min · .env files, python-dotenv, Pydantic Settings, config patterns

Ref
Primary Source
Pydantic Settings — Official Docs

The modern, type-safe way to manage configuration. Also see python-dotenv docs for the simpler alternative.

1 — The Problem: Config Everywhere

JavaScript / Node.js
// .env file
DATABASE_URL=postgres://localhost/dev
SECRET_KEY=my-secret
DEBUG=true

// Usage
import dotenv from "dotenv";
dotenv.config();

const dbUrl = process.env.DATABASE_URL;
const debug = process.env.DEBUG === "true";  // manual coercion!

// Or with Zod for validation
const config = z.object({
  DATABASE_URL: z.string().url(),
  SECRET_KEY: z.string().min(32),
  PORT: z.coerce.number().default(8080),
}).parse(process.env);
Python — two approaches
# 1. Simple: python-dotenv
# pip install python-dotenv
from dotenv import load_dotenv
import os

load_dotenv()  # loads .env into os.environ
db_url = os.environ["DATABASE_URL"]
debug = os.getenv("DEBUG", "false").lower() == "true"

# 2. Modern: Pydantic Settings (recommended)
# pip install pydantic-settings
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    secret_key: str
    port: int = 8080
    debug: bool = False

    class Config:
        env_file = ".env"

settings = Settings()   # reads .env + validates types automatically!

2 — python-dotenv (simple, no validation)

# .env
DATABASE_URL=postgresql://user:pass@localhost/mydb
SECRET_KEY=super-secret-key-32-chars-minimum
DEBUG=true
PORT=8080
ALLOWED_HOSTS=localhost,127.0.0.1
# config.py
from dotenv import load_dotenv
import os

# Load .env into environment (won't override existing env vars)
load_dotenv()                      # from .env in cwd
load_dotenv(".env.local")          # from specific file
load_dotenv(override=True)         # override existing env vars

# Access
DATABASE_URL = os.environ["DATABASE_URL"]         # KeyError if missing
SECRET_KEY   = os.getenv("SECRET_KEY", "default") # safe, with default

# Manual type conversion (the annoying part)
PORT    = int(os.getenv("PORT", "8080"))
DEBUG   = os.getenv("DEBUG", "false").lower() == "true"
HOSTS   = os.getenv("ALLOWED_HOSTS", "").split(",")

# Never hardcode secrets — always use env vars
# Never commit .env to git — add it to .gitignore!

3 — Pydantic Settings (recommended for real projects)

# pip install pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import AnyUrl, SecretStr

class Settings(BaseSettings):
    # Types are enforced and coerced automatically!
    database_url: AnyUrl
    secret_key: SecretStr         # masked in logs/repr
    port: int = 8080
    debug: bool = False
    allowed_hosts: list[str] = ["localhost"]

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,     # DATABASE_URL = database_url
    )

# Singleton pattern — import this from anywhere
settings = Settings()

# Usage
print(settings.port)              # 8080 (int, not "8080")
print(settings.debug)             # False (bool, not "false")
print(settings.secret_key)        # SecretStr('**********')
print(settings.secret_key.get_secret_value())  # actual value
💡 Validation is free

Pydantic Settings validates at startup. If DATABASE_URL is missing or PORT is not a number, you get a clear error immediately — not a mysterious crash 10 minutes later when your code tries to use the bad value.

4 — Multiple Environments

# Load different .env files per environment
import os
from dotenv import load_dotenv

env = os.getenv("APP_ENV", "development")
load_dotenv(f".env.{env}")   # .env.development, .env.production, etc.

# With Pydantic Settings
class Settings(BaseSettings):
    app_env: str = "development"
    database_url: str
    debug: bool = False

    model_config = SettingsConfigDict(
        env_file=(
            ".env",            # base — always loaded
            f".env.{os.getenv('APP_ENV', 'development')}",  # override
        )
    )
# .gitignore — never commit secrets
.env
.env.local
.env.production
*.key

# .env.example — commit this (with fake values as docs)
DATABASE_URL=postgresql://user:pass@localhost/mydb
SECRET_KEY=change-me-to-a-32-char-random-string
PORT=8080
DEBUG=false

5 — Config Patterns for Larger Apps

# config.py — central config module
from functools import lru_cache
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    # App
    app_name: str = "My App"
    app_env: str = "development"
    debug: bool = False

    # Database
    database_url: str
    db_pool_size: int = 5

    # Auth
    secret_key: str
    token_expire_minutes: int = 60

    # External APIs
    github_token: str | None = None
    stripe_key: str | None = None

    model_config = SettingsConfigDict(env_file=".env")

    @property
    def is_production(self) -> bool:
        return self.app_env == "production"

    @property
    def is_development(self) -> bool:
        return self.app_env == "development"

@lru_cache          # only parse env once
def get_settings() -> Settings:
    return Settings()

# Usage throughout your app:
from config import get_settings
settings = get_settings()

if settings.debug:
    print("Debug mode on")

🧠 Quiz

1. What does os.getenv("PORT", "8080") return?

2. What's the advantage of Pydantic Settings over plain os.environ?

3. Which file should you commit to git instead of .env?

0/3

Questions answered correctly.