Configuration is one of those things that separates a demo from a real application. Your APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. needs to know the database URL, secret keys, API tokens, feature flags, and port numbers. Where this information lives and how it gets into your app determines whether your project is deployable, secure, and maintainable. AI gets this wrong more often than almost anything else.
The problem AI creates
Here is what AI typically generates when you ask for a FastAPI app with database access:
# main.py - the AI-generated version
from fastapi import FastAPI
from sqlalchemy import create_engine
app = FastAPI()
DATABASE_URL = "postgresql://admin:password123@localhost:5432/mydb"
JWT_SECRET = "super-secret-key-dont-share"
DEEPSEEK_API_KEY = "sk-abc123def456"
engine = create_engine(DATABASE_URL)This code works. It also has three critical problems:
- Secrets in source code. If this gets pushed to GitHub (and it will, people forget), your database credentials and APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. keys are public. Bots scrape GitHub for exactly these patterns.
- No environment separation. The same code runs against your production database in development. One wrong
DELETEquery and your production data is gone. - No validation. If someone deploys this without setting up the database, it crashes at runtimeWhat is runtime?The environment that runs your code after it's written. Some languages need a runtime installed on the machine; others (like Go) bake it into the binary. with a confusing connection error instead of a clear "DATABASE_URL is missing" message at startup.
Enter pydantic-settings
The pydantic-settings library solves all three problems. It reads configuration from environment variables, validates them at startup, and gives you typed access throughout your app.
pip install pydantic-settings# app/core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# Database
database_url: str
database_echo: bool = False
# Auth
jwt_secret: str
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30
# External APIs
deepseek_api_key: str
# App
app_name: str = "My API"
debug: bool = False
allowed_origins: list[str] = ["http://localhost:3000"]This class does several things at once:
- Reads from environment variables.
database_urlreads fromDATABASE_URL(case-insensitive by default). - Falls back to
.envfile. If the environment variableWhat is environment variable?A value stored outside your code that configures behavior per deployment, commonly used for secrets like API keys and database URLs. is not set, it reads from the.envfile in the project root. - Validates at startup. If
database_urlis missing and has no default, the app crashes immediately with a clear error:ValidationError: database_url field required. - Provides type safety.
access_token_expire_minutesis always anint. If someone sets it to"thirty", Pydantic catches it.
The .env file pattern
Your .env file contains the actual secrets. Your .env.example documents the required variables without real values.
# .env - NEVER commit this
DATABASE_URL=postgresql://admin:realpassword@localhost:5432/mydb
JWT_SECRET=a-real-random-string-here
DEEPSEEK_API_KEY=sk-real-key-here
DEBUG=true# .env.example - DO commit this
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
JWT_SECRET=change-me-to-a-random-string
DEEPSEEK_API_KEY=your-api-key-here
DEBUG=falseYour .gitignore must include .env:
# .gitignore
.env
.env.local
.env.production.gitignore that includes .env, and it almost never creates a .env.example file. When reviewing AI-generated projects, check for both immediately. A missing .gitignore entry is a ticking time bomb.Settings as a dependencyWhat is dependency?A piece of code written by someone else that your project needs to work. Think of it as a building block you import instead of writing yourself.
The cleanest way to use settings in FastAPI is as a dependency. This makes your routes testable (you can override settings in tests) and ensures every part of your app gets the same configuration instance.
# app/core/config.py
from functools import lru_cache
@lru_cache
def get_settings() -> Settings:
return Settings()The @lru_cache decorator ensures the Settings object is created exactly once and reused for every request. Without it, every request would re-read the .env file and re-validate everything, wasteful and slow.
# app/routers/items.py
from fastapi import APIRouter, Depends
from core.config import Settings, get_settings
router = APIRouter()
@router.get("/debug")
def debug_info(settings: Settings = Depends(get_settings)):
return {
"app_name": settings.app_name,
"debug": settings.debug,
}In tests, you can override the dependency:
# tests/conftest.py
from core.config import get_settings, Settings
def get_test_settings():
return Settings(
database_url="sqlite:///test.db",
jwt_secret="test-secret",
deepseek_api_key="test-key",
)
app.dependency_overrides[get_settings] = get_test_settingsNested settings for complex configuration
As your configuration grows, you can organize it into nested groups:
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseSettings(BaseModel):
url: str
echo: bool = False
pool_size: int = 5
class AuthSettings(BaseModel):
jwt_secret: str
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
)
database: DatabaseSettings
auth: AuthSettings
debug: bool = FalseWith env_nested_delimiter="__", you set nested values like this:
DATABASE__URL=postgresql://localhost/mydb
DATABASE__POOL_SIZE=10
AUTH__JWT_SECRET=my-secretThis keeps your settings organized as the project grows. Instead of 30 flat variables, you get logical groups that mirror your application's structure.
Startup validation pattern
The best practice is to validate settings the moment your app starts, not when the first request hits a route that needs them:
# app/main.py
from fastapi import FastAPI
from core.config import get_settings
def create_app() -> FastAPI:
settings = get_settings() # Crashes immediately if config is invalid
app = FastAPI(
title=settings.app_name,
debug=settings.debug,
)
# Mount routers...
return app
app = create_app()If DATABASE_URL is missing, the app fails at startup with a clear Pydantic validation error, not 10 minutes later when someone hits an endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. that needs the database. This is the difference between a helpful error message and a 3 AM debugging sessionWhat is session?A server-side record that tracks a logged-in user. The browser holds only a session ID in a cookie, and the server looks up the full data on each request..
Configuration checklist
When reviewing AI-generated FastAPI code, check for these:
| Check | What to look for |
|---|---|
| No hardcoded secrets | Search for = followed by what looks like passwords, keys, or connection strings |
.env in .gitignore | Must be present before the first commit |
.env.example exists | Documents every required variable |
| Settings validated at startup | App should fail fast if config is missing |
| Settings used as dependency | Not imported as a global, enables testing |
| No secrets in logs | print(settings) should not dump your API keys |
This is one area where you cannot trust AI defaults. Configuration security requires human judgment every single time.