Environment Variables
Your API needs configuration: database URLs, API keys, secret tokens. Hardcoding these values is a security risk and makes deployment painful. Environment variables solve this—they let you configure your app differently in development, staging, and production without changing code.
This lesson teaches the pattern you'll use throughout the chapter. Every lesson from here forward uses settings from environment variables.
Why Environment Variables Matter
The problem with hardcoding:
# NEVER DO THIS
database_url = "postgresql://admin:password123@db.example.com/prod"
api_key = "sk-secret-key-that-leaks-to-github"
If this code gets committed:
- Secrets leak to version control
- You can't use different databases in dev vs prod
- Rotating secrets requires code changes and redeployment
The environment variable solution:
import os
database_url = os.getenv("DATABASE_URL")
api_key = os.getenv("API_KEY")
Now configuration lives outside your code. Different environments set different values.
pydantic-settings: Type-Safe Configuration
Raw os.getenv() works but has problems:
- Returns strings only (need to convert
"30"to30) - No validation (missing variables fail silently)
- No documentation of required variables
pydantic-settings fixes all of this:
uv add pydantic-settings
Create config.py:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application configuration from environment variables."""
database_url: str
api_key: str
debug: bool = False
max_connections: int = 10
What this gives you:
- Type conversion:
"10"becomes10,"true"becomesTrue - Validation: Missing required variables raise clear errors
- Defaults: Optional variables have default values
- Documentation: The class itself documents what's configurable
Using Settings in Your App
Create main.py:
from fastapi import FastAPI, Depends
from config import Settings
app = FastAPI()
def get_settings() -> Settings:
return Settings()
@app.get("/config")
def show_config(settings: Settings = Depends(get_settings)):
return {
"debug": settings.debug,
"max_connections": settings.max_connections
}
Output (with DEBUG=true set):
{
"debug": true,
"max_connections": 10
}
The .env File
Typing export DATABASE_URL=... every time is tedious. Use a .env file:
# .env
DATABASE_URL=postgresql://localhost/devdb
API_KEY=dev-key-not-for-production
DEBUG=true
MAX_CONNECTIONS=5
Tell pydantic-settings to load it:
class Settings(BaseSettings):
database_url: str
api_key: str
debug: bool = False
max_connections: int = 10
class Config:
env_file = ".env"
Now Settings() reads from .env automatically.
Critical: Gitignore Your Secrets
Never commit .env files with real secrets.
Create .gitignore:
# .gitignore
.env
*.env
.env.*
!.env.example
Create .env.example (this one IS committed):
# .env.example - Copy to .env and fill in values
DATABASE_URL=postgresql://user:pass@host/database
API_KEY=your-api-key-here
DEBUG=false
MAX_CONNECTIONS=10
This pattern:
- Documents what variables are needed
- Keeps actual secrets out of version control
- Makes onboarding new developers easy
Caching Settings
Creating Settings() reads from disk each time. Cache it:
from functools import lru_cache
@lru_cache
def get_settings() -> Settings:
return Settings()
Now settings load once and reuse the same instance. This is more efficient and ensures consistency.
Complete Settings Example
Here's the pattern you'll use throughout this chapter. Create config.py:
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
"""Application settings loaded from environment."""
# Database
database_url: str
# Authentication
secret_key: str
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
# API Keys
anthropic_api_key: str
# Development
debug: bool = False
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
"""Cached settings instance."""
return Settings()
Create main.py:
from fastapi import FastAPI, Depends
from config import Settings, get_settings
app = FastAPI()
@app.get("/health")
def health_check(settings: Settings = Depends(get_settings)):
return {
"status": "healthy",
"debug_mode": settings.debug
}
Output:
{
"status": "healthy",
"debug_mode": false
}
Validation Errors
What happens with missing or invalid values?
# .env is empty or missing DATABASE_URL
settings = Settings()
Output:
pydantic_settings.sources.SettingsError: error loading settings
database_url
Field required [type=missing]
Clear error message telling you exactly what's missing.
# .env has MAX_CONNECTIONS=not_a_number
settings = Settings()
Output:
pydantic_settings.sources.SettingsError: error loading settings
max_connections
Input should be a valid integer [type=int_parsing]
These errors happen at startup, not during a request. Fail fast.
Hands-On Exercise
Set up configuration for your Task API:
Step 1: Install pydantic-settings:
uv add pydantic-settings
Step 2: Create config.py:
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
app_name: str = "Task API"
debug: bool = False
max_tasks_per_user: int = 100
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
return Settings()
Step 3: Create .env:
APP_NAME=My Task API
DEBUG=true
MAX_TASKS_PER_USER=50
Step 4: Create .gitignore:
.env
Step 5: Create .env.example:
APP_NAME=Task API
DEBUG=false
MAX_TASKS_PER_USER=100
Step 6: Use settings in your app:
from fastapi import FastAPI, Depends
from config import Settings, get_settings
app = FastAPI()
@app.get("/info")
def app_info(settings: Settings = Depends(get_settings)):
return {
"app_name": settings.app_name,
"debug": settings.debug,
"max_tasks": settings.max_tasks_per_user
}
Step 7: Test it:
curl http://localhost:8000/info
Output:
{
"app_name": "My Task API",
"debug": true,
"max_tasks": 50
}
Common Mistakes
Mistake 1: Committing .env with secrets
# Check if .env is tracked
git status
# If it shows .env, remove it from tracking
git rm --cached .env
echo ".env" >> .gitignore
git commit -m "Remove .env from tracking"
Mistake 2: Wrong variable names
class Settings(BaseSettings):
database_url: str # Expects DATABASE_URL in environment
Environment variables are case-insensitive but conventionally UPPER_CASE. pydantic-settings converts database_url to look for DATABASE_URL.
Mistake 3: Forgetting the Config class
# Wrong - won't read from .env
class Settings(BaseSettings):
database_url: str
# Correct
class Settings(BaseSettings):
database_url: str
class Config:
env_file = ".env"
Mistake 4: Not caching settings
# Wrong - reads file on every call
def get_settings():
return Settings()
# Correct - reads once
@lru_cache
def get_settings():
return Settings()
Security Checklist
Before deploying any API:
-
.envis in.gitignore -
.env.exampledocuments required variables - No secrets appear in code or comments
- Production uses different secrets than development
- Secret rotation doesn't require code changes
Try With AI
After completing the exercise, explore these scenarios.
Prompt 1: Environment-Specific Settings
I have Settings that work for development. How do I handle different
configurations for production? For example:
- Development: DEBUG=true, local database
- Production: DEBUG=false, remote database
Should I use multiple .env files or conditional logic in Settings?
What you're learning: Real deployments need environment-specific configuration. There are several patterns—AI can explain tradeoffs.
Prompt 2: Validating Settings
I want to validate that my DATABASE_URL is a valid PostgreSQL
connection string, not just any string. Can pydantic-settings
validate the format of environment variables?
What you're learning: pydantic validators work with settings too. You can enforce URL formats, string patterns, and more.
Prompt 3: Secrets in Production
In development, I use .env files. In production on Railway or
Fly.io, how do I set environment variables? What's the best
practice for managing production secrets?
What you're learning: .env is for local development. Production platforms have their own secrets management—understanding this completes the picture.
Reflect on Your Skill
You built a fastapi-agent skill in Lesson 0. Test and improve it based on what you learned.
Test Your Skill
Using my fastapi-agent skill, help me set up environment variable configuration.
Does my skill include Settings class with pydantic-settings and .env file handling?
Identify Gaps
Ask yourself:
- Did my skill include BaseSettings class with pydantic-settings?
- Did it handle .env file loading and .gitignore configuration?
- Did it use lru_cache for settings and Depends() for injection?
Improve Your Skill
If you found gaps:
My fastapi-agent skill is missing environment configuration patterns.
Update it to include pydantic-settings BaseSettings, .env file usage,
.gitignore for secrets, and cached dependency injection for configuration.