Configuration
Table of Contents
Managing application settings with pydantic-settings — type-safe, validated, and environment-aware.
The Settings Class
# src/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore", # don't crash on unknown env vars
)
app_name: str = "My API"
debug: bool = False
database_url: str = "sqlite+aiosqlite:///dev.db"
cors_origins: list[str] = ["*"]Values are resolved in this order (first match wins):
- Environment variables (case-insensitive)
.envfile- Field defaults
# .env
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/mydbNote
extra="ignore"is important in practice. Without it, any unknown environment variable that happens to match a prefix will cause a validation error at startup.
For how to inject Settings into route handlers via Depends, see the Dependency Injection page.
Nested Settings
As the settings class grows, group related config into nested BaseModel sub-models. Set env_nested_delimiter on the root Settings so pydantic-settings can map environment variables like DB__POOL_SIZE to settings.db.pool_size:
# src/config.py
from pydantic import BaseModel, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseSettings(BaseModel):
url: str = "sqlite+aiosqlite:///dev.db"
password: SecretStr = SecretStr("")
pool_size: int = 5
echo: bool = False
class RedisSettings(BaseModel):
url: str = "redis://localhost:6379/0"
ttl: int = 3600
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__",
extra="ignore",
)
app_name: str = "My API"
debug: bool = False
cors_origins: list[str] = ["*"]
db: DatabaseSettings = DatabaseSettings()
redis: RedisSettings = RedisSettings()# .env
DB__URL=postgresql+asyncpg://user:pass@localhost/mydb
DB__PASSWORD=s3cret
DB__POOL_SIZE=20
REDIS__URL=redis://cache:6379/0The field name becomes the prefix automatically — db: DatabaseSettings maps to DB__* env vars. Access in code reads naturally: settings.db.url, settings.redis.ttl.
Note
Nested sub-models inherit from
BaseModel, notBaseSettings. Only the root class should beBaseSettings— this is the pattern the pydantic-settings docs document for nested configuration.env_prefixon sub-models has no effect; the delimiter handles nesting.
Why use pydantic-settings when we have…
dotenv
python-dotenv loads .env into os.environ and gives you strings back. You still have to cast types, set defaults, and validate manually. pydantic-settings does all of that declaratively — types, defaults, validation, and .env loading in one class.
os.getenv
os.getenv("DATABASE_URL", "sqlite:///dev.db") works for one or two values. At ten settings it becomes a scattered mess of string casts, missing-key bugs, and no single place to see what the app expects. A Settings class is that single place, and you get type errors at startup instead of runtime KeyErrors in production.
Also, with a Settings object you get two ways to control config in tests: swap the entire object via dependency_overrides (see Dependency Injection), or patch individual env vars and let Settings() re-read them. Neither option exists when config is scattered across bare os.getenv calls.
Practical tips
Use SecretStr for any field that holds a secret (passwords, API keys, tokens). It prevents the value from leaking into logs, tracebacks, and model_dump() output:
from pydantic import SecretStr
password: SecretStr = SecretStr("")
settings.db.password.get_secret_value() # access the actual value
str(settings.db.password) # → '**********'