Application configuration looks easy when a project has only two or three values. As soon as you add databases, external APIs, development and production environments, background workers, feature flags, and tests, configuration can become a fragile collection of hard-coded strings and scattered os.getenv() calls. Pydantic Settings provides a cleaner approach: declare settings as typed fields, load values from environment variables or .env files, and validate everything when the application starts.
This guide shows how to create a central settings class, require important values, convert booleans and numbers, protect secrets, organize nested configuration, separate environments, test settings, and integrate them with FastAPI. The goal is not to add complexity. It is to make configuration predictable, documented, and safe.
Why configuration should be centralized
Scattered environment lookups create several problems. One module may treat a missing value as empty text, another may apply a default, and a third may crash much later. Ports arrive as strings, boolean values are frequently interpreted incorrectly, and invalid URLs may remain unnoticed until a request fails.
A central settings object validates the complete configuration at startup. If a required value is missing or malformed, the program fails early with a useful error. This is far better than starting successfully and failing after users begin sending requests. For the fundamentals, see our guide to reading environment variables safely in Python.
Install Pydantic Settings
Create a virtual environment and install the package:
python -m venv .venv
# Windows: .venv\Scripts\activate
# Linux/macOS: source .venv/bin/activate
pip install pydantic-settingsThe package builds on Pydantic and exposes BaseSettings. Keeping dependencies inside a project-specific environment also prevents version conflicts. Review the tutorial on Python virtual environments with venv if you need a refresher.
Create your first settings class
Create config.py:
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "My API"
debug: bool = False
port: int = 8000
database_url: str
api_key: SecretStr
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
settings = Settings()The class documents every expected value and its type. database_url and api_key are required because they have no defaults. SecretStr prevents the full secret from appearing in normal object representations and accidental logs.
Create a local .env file:
APP_NAME=Academify API
DEBUG=true
PORT=8080
DATABASE_URL=sqlite:///app.db
API_KEY=local_secretWhen Settings() is instantiated, Pydantic converts true into a Boolean and 8080 into an integer. If conversion is impossible, validation fails immediately.
Use prefixes to avoid collisions
Large servers often host multiple services. A prefix makes ownership clear:
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="APP_",
extra="ignore",
)The field database_url will now read from APP_DATABASE_URL. Prefixes also improve documentation in deployment platforms where hundreds of environment variables may be visible.
Validate ranges and formats
Typing is only the first layer. Use constraints to reject values that are technically the right type but operationally invalid:
from pydantic import Field
class Settings(BaseSettings):
port: int = Field(default=8000, ge=1, le=65535)
workers: int = Field(default=1, ge=1, le=32)
timeout_seconds: float = Field(default=10.0, gt=0)A negative timeout or port above 65535 is rejected before the server starts. This approach fits naturally with the concepts explained in Python type hints for cleaner code.
Lists, dictionaries, and JSON values
Settings can contain structured data:
class Settings(BaseSettings):
allowed_hosts: list[str] = ["localhost"]
feature_flags: dict[str, bool] = {}Provide complex values as JSON:
ALLOWED_HOSTS=["academify.com.br", "api.academify.com.br"]
FEATURE_FLAGS={"new_login": true, "cache": false}JSON avoids ambiguous comma splitting and preserves types inside collections.
Nested configuration
As a project grows, group related values into models:
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseSettings(BaseModel):
host: str = "localhost"
port: int = 5432
name: str = "app"
class Settings(BaseSettings):
database: DatabaseSettings = DatabaseSettings()
model_config = SettingsConfigDict(
env_nested_delimiter="__"
)You can now set DATABASE__HOST and DATABASE__PORT. Nested models keep the Python interface readable while remaining compatible with standard deployment systems.
Integrate settings with FastAPI
A FastAPI application should not rebuild settings on every request. Cache the object and inject it as a dependency:
from functools import lru_cache
from fastapi import Depends, FastAPI
app = FastAPI()
@lru_cache
def get_settings() -> Settings:
return Settings()
@app.get("/info")
def info(config: Settings = Depends(get_settings)):
return {
"app_name": config.app_name,
"debug": config.debug,
}The cache creates one settings object per process. This pattern combines well with our guide to building APIs with FastAPI and avoids repeated file reads and validation.
Separate development, test, and production
Do not store every environment in one file. A simple local strategy selects a file using an external variable:
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
ENV = os.getenv("APP_ENV", "development")
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=f".env.{ENV}",
extra="ignore",
)You may keep .env.development and .env.test locally. Production secrets, however, should come from the hosting platform, container runtime, Kubernetes Secret, or a dedicated secret manager. Never commit real credentials. The same principle is useful when you run Python in Docker.
Test configuration without relying on the machine
Instantiate settings directly in tests:
def test_settings():
config = Settings(
database_url="sqlite:///:memory:",
api_key="test",
debug=True,
)
assert config.debug is True
assert config.port == 8000With pytest, monkeypatch can create temporary environment variables. This keeps tests deterministic and prevents a developer’s local values from changing results.
Common mistakes
- Creating settings in every module: centralize construction or use caching.
- Committing secrets: commit only a documented
.env.example. - Using strings for everything: declare Booleans, integers, lists, URLs, and constrained fields.
- Ignoring validation errors: startup failure is useful when configuration is invalid.
- Logging complete settings: avoid unnecessary output even when using
SecretStr. - Assuming the wrong priority: real environment variables should normally override local files.
Production checklist
Use consistent names, document every variable, rotate credentials, restrict permissions, validate ranges and formats, and provide secrets only at runtime. Add health checks that confirm required dependencies are reachable without exposing secret values. In CI pipelines, rely on the platform’s protected secret storage rather than plain repository variables.
The official Pydantic Settings documentation covers sources, aliases, nested models, command-line support, and customization. The SecretStr reference explains how secret values are represented and serialized.
Conclusion
Pydantic Settings turns environment variables into a typed, validated interface. Instead of spreading parsing logic and defaults across a codebase, you define one model that documents what the application needs. Invalid deployment data is discovered early, tests become easier, and sensitive values are handled more carefully.
Start with a small class, add constraints that reflect real operational limits, and separate environments as the project grows. Good configuration management is not just code organization; it is a reliability and security feature.







