The modern, type-safe way to manage configuration. Also see python-dotenv docs for the simpler alternative.
// .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);
# 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!
# .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!
# 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
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.
# 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
# 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")
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?
Questions answered correctly.