Intermediate
You have a working Python app. It connects to a database, calls an external API, and fires emails — all configured by a scattered mix of os.environ.get() calls, hardcoded defaults, and a README that says “remember to set these env vars before running.” Then a teammate joins the project, misses one variable, and gets a cryptic None error three function calls deep. Sound familiar?
Configuration management is one of those problems that feels solved until your app grows. The standard library gives you os.environ, but it has no validation, no types, and no structure. Enter pydantic-settings: a library built on top of Pydantic V2 that lets you define your configuration as a typed model, load it automatically from environment variables or .env files, and get clear validation errors the moment something is missing or wrong — at startup, not buried inside a request handler.
In this article, we will cover how to install and configure pydantic-settings, how to use BaseSettings to define typed config models, how to load from .env files and environment variables, how to handle nested settings and multiple sources, and how to build a real-world app config system. By the end, you will have a battle-tested configuration pattern you can drop into any Python project.
Pydantic Settings: Quick Example
Here is the simplest working example — a settings model that reads from environment variables and validates types automatically:
# quick_example.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "MyApp"
debug: bool = False
port: int = 8000
database_url: str
settings = Settings(database_url="postgresql://localhost/mydb")
print(settings.app_name)
print(settings.debug)
print(settings.port)
print(settings.database_url)
Output:
MyApp
False
8000
postgresql://localhost/mydb
The Settings class inherits from BaseSettings, which tells Pydantic to look for field values in environment variables first, then fall back to defaults. If you set PORT=9000 in your shell, settings.port will be 9000 — no extra code needed. If a required field like database_url is missing entirely, you get a clear ValidationError at import time, not a silent None somewhere unexpected.
The sections below go deeper: loading from .env files, handling secrets, nested settings, multiple environments, and a production-ready config pattern.
What Is pydantic-settings and Why Use It?
pydantic-settings is an official companion library to Pydantic V2, maintained by the Pydantic team. It extends Pydantic’s data validation model to handle configuration sources: environment variables, .env files, JSON files, secrets directories, and custom providers. The key idea is that your configuration is just a Pydantic model, so all of Pydantic’s validation and type coercion features work automatically.
Compare the main approaches to Python config management:
| Approach | Type safety | .env support | Validation errors | Complexity |
|---|---|---|---|---|
os.environ.get() | None (all strings) | Manual | None (silent None) | Low |
configparser | Limited | No | Weak | Medium |
python-decouple | Partial | Yes | Basic | Low |
pydantic-settings | Full Pydantic V2 | Yes | Clear ValidationError | Low-Medium |
dynaconf | Limited | Yes | Basic | High |
The big win with pydantic-settings is that you get the full Pydantic validator ecosystem — field constraints, custom validators, computed fields, aliases — without learning a separate config DSL. If you already use Pydantic for your data models, adding pydantic-settings for config is a natural extension of the same pattern.
Installation and Setup
Install with pip. Note that pydantic-settings is a separate package from pydantic itself — you need both:
# install.sh
pip install pydantic-settings
# This also installs pydantic V2 as a dependency if not already present.
# To confirm:
python -m pip show pydantic-settings
Output:
Name: pydantic-settings
Version: 2.x.x
Requires: pydantic, python-dotenv
Notice that python-dotenv is included as a dependency — pydantic-settings uses it under the hood to load .env files, so you do not need to install or import it separately.
Loading from .env Files
The most common pattern is defining settings that load from both environment variables and a .env file. Configure this by adding a nested model_config using SettingsConfigDict:
# settings_env.py
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", # load from this file
env_file_encoding="utf-8",
case_sensitive=False, # PORT and port both match
extra="ignore", # silently ignore unknown env vars
)
app_name: str = "MyApp"
debug: bool = False
port: int = 8000
database_url: str
api_key: str
Create a .env file in the same directory:
# .env
APP_NAME=ProdApp
DEBUG=false
PORT=8080
DATABASE_URL=postgresql://user:pass@localhost/mydb
API_KEY=sk-abc123
Now instantiate settings — no arguments needed:
# run_settings.py
from settings_env import Settings
settings = Settings()
print(settings.app_name)
print(settings.port)
print(settings.debug)
Output:
ProdApp
8080
False
The field types enforce automatic coercion: the string "8080" from the env file becomes the integer 8080, and "false" becomes the boolean False — both happen automatically with no parsing code. If a field value cannot be coerced (say, PORT=not_a_number), you get a ValidationError immediately at startup listing every broken field, which is far better than discovering it when the server tries to bind a port.
Field Constraints and Custom Validators
Because BaseSettings is just a Pydantic model, every Pydantic field constraint works exactly as you’d expect. Use Field() to add defaults, descriptions, and range constraints:
# validated_settings.py
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
port: int = Field(default=8000, ge=1024, le=65535, description="HTTP server port")
workers: int = Field(default=4, ge=1, le=32, description="Number of worker processes")
log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$")
secret_key: str = Field(min_length=32, description="App secret key, must be 32+ chars")
settings = AppSettings()
print(f"Running on port {settings.port} with {settings.workers} workers")
print(f"Log level: {settings.log_level}")
Output (with valid .env):
Running on port 8080 with 4 workers
Log level: INFO
If PORT=999 (below the ge=1024 minimum), you get:
pydantic_core._pydantic_core.ValidationError: 1 validation error for AppSettings
port
Input should be greater than or equal to 1024 [type=greater_than_equal, input_value=999]
The pattern constraint on log_level ensures no one accidentally sets LOG_LEVEL=verbose and wonders why logging is not working. These constraints run at startup, so misconfigured deployments fail fast and loudly rather than producing subtle misbehavior in production.
Nested Settings Models
Real apps have groups of related settings — database config, cache config, email config. Nest them as sub-models inside your main BaseSettings. Pydantic handles the prefix-based env var lookup automatically:
# nested_settings.py
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseConfig(BaseModel):
host: str = "localhost"
port: int = 5432
name: str = "mydb"
user: str = "postgres"
password: str = ""
class CacheConfig(BaseModel):
host: str = "localhost"
port: int = 6379
ttl: int = 300 # seconds
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_nested_delimiter="__" # DB__HOST maps to database.host
)
app_name: str = "MyApp"
database: DatabaseConfig = DatabaseConfig()
cache: CacheConfig = CacheConfig()
settings = Settings()
print(f"DB: {settings.database.host}:{settings.database.port}/{settings.database.name}")
print(f"Cache TTL: {settings.cache.ttl}s")
In the .env file, use double-underscore to set nested values:
# .env (nested example)
DATABASE__HOST=db.example.com
DATABASE__PORT=5432
DATABASE__NAME=production_db
DATABASE__USER=admin
DATABASE__PASSWORD=secretpass
CACHE__HOST=redis.example.com
CACHE__TTL=600
Output:
DB: db.example.com:5432/production_db
Cache TTL: 600s
The env_nested_delimiter="__" setting tells pydantic-settings to split on double-underscores and map to nested model attributes. Sub-models like DatabaseConfig use plain BaseModel, not BaseSettings — only the root settings class needs the BaseSettings inheritance.
Multiple Environments
A practical pattern is loading different .env files based on an environment variable like APP_ENV. This is cleaner than maintaining one large file with commented-out sections:
# multi_env_settings.py
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
APP_ENV = os.getenv("APP_ENV", "development")
ENV_FILE = f".env.{APP_ENV}" # .env.development, .env.production, .env.test
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(".env", ENV_FILE), # tuple: base .env first, then override
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "MyApp"
debug: bool = True
database_url: str = "sqlite:///dev.db"
log_level: str = "DEBUG"
settings = Settings()
print(f"[{APP_ENV.upper()}] {settings.app_name}")
print(f"Debug: {settings.debug}, Log: {settings.log_level}")
Create environment-specific files:
# .env (shared base)
APP_NAME=MyApp
# .env.production
DEBUG=false
DATABASE_URL=postgresql://prod-server/mydb
LOG_LEVEL=WARNING
Output (with APP_ENV=production):
[PRODUCTION] MyApp
Debug: False, Log: WARNING
When env_file is a tuple, files are loaded in order and later files override earlier ones. This lets .env hold shared defaults while .env.production adds production-specific overrides. The real environment variables (set in the shell or via a secrets manager) always take the highest priority over any file.
Handling Secrets Safely
For production deployments, storing secrets in .env files is not ideal — they can end up in version control. pydantic-settings has a built-in SecretsSettingsSource that reads secrets from a directory of files (the pattern used by Docker secrets and Kubernetes secrets):
# secrets_settings.py
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class ProductionSettings(BaseSettings):
model_config = SettingsConfigDict(
secrets_dir="/run/secrets", # Docker/K8s secrets mount point
env_file=".env",
)
app_name: str = "MyApp"
database_url: str # read from /run/secrets/database_url
api_key: str # read from /run/secrets/api_key
# For local development, create a mock secrets directory:
# mkdir -p /tmp/secrets
# echo "postgresql://localhost/mydb" > /tmp/secrets/database_url
# echo "sk-dev-key-abc123" > /tmp/secrets/api_key
dev_settings = ProductionSettings(
_secrets_dir="/tmp/secrets" # override for local dev
)
print(dev_settings.app_name)
print(dev_settings.database_url[:30] + "...")
Output:
MyApp
postgresql://localhost/mydb...
Each secret is stored as a separate file in the secrets directory, with the filename matching the field name. This pattern integrates directly with Docker’s --secret flag and Kubernetes Secret volume mounts, making it straightforward to move from local .env files to a proper secrets management system in production without changing any application code.
The Singleton Settings Pattern
In most apps, you want settings loaded once at startup and reused everywhere. The cleanest way to do this with pydantic-settings uses a module-level singleton with lru_cache:
# config.py
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
app_name: str = "MyApp"
debug: bool = False
database_url: str = "sqlite:///app.db"
secret_key: str = "change-me-in-production-please"
api_timeout: int = 30
@lru_cache
def get_settings() -> Settings:
return Settings()
# In your app modules, import and call:
# from config import get_settings
# settings = get_settings()
Then in any module:
# database.py
from config import get_settings
def get_db_connection():
settings = get_settings() # returns the same instance every time
return settings.database_url
# main.py
from config import get_settings
settings = get_settings()
print(f"Starting {settings.app_name} in {'DEBUG' if settings.debug else 'PRODUCTION'} mode")
print(f"API timeout: {settings.api_timeout}s")
Output:
Starting MyApp in PRODUCTION mode
API timeout: 30s
The @lru_cache decorator ensures Settings() is only constructed once. This means the .env file is only read once at startup, validation only runs once, and every module that calls get_settings() gets the same object. In testing, you can call get_settings.cache_clear() to force a fresh settings load with different environment variables.
Real-Life Example: FastAPI App Config System
Here is a complete configuration system for a FastAPI application, demonstrating multiple settings groups, environment switching, and the singleton pattern all working together:
# app_config.py -- Production-ready pydantic-settings config for a FastAPI app
import os
from functools import lru_cache
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import BaseModel
class DatabaseSettings(BaseModel):
host: str = "localhost"
port: int = 5432
name: str = "app_db"
user: str = "postgres"
password: str = ""
pool_size: int = 5
@property
def url(self) -> str:
return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.name}"
class RedisSettings(BaseModel):
host: str = "localhost"
port: int = 6379
db: int = 0
@property
def url(self) -> str:
return f"redis://{self.host}:{self.port}/{self.db}"
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(".env", f".env.{os.getenv('APP_ENV', 'development')}"),
env_file_encoding="utf-8",
env_nested_delimiter="__",
extra="ignore",
)
# Core app settings
app_name: str = "FastAPI App"
version: str = "1.0.0"
debug: bool = False
secret_key: str = Field(min_length=16)
allowed_hosts: list[str] = ["localhost", "127.0.0.1"]
# Nested config groups
database: DatabaseSettings = DatabaseSettings()
redis: RedisSettings = RedisSettings()
# External services
smtp_host: str = "smtp.example.com"
smtp_port: int = 587
from_email: str = "noreply@example.com"
@field_validator("allowed_hosts", mode="before")
@classmethod
def parse_allowed_hosts(cls, v):
# Allow comma-separated string from env var
if isinstance(v, str):
return [h.strip() for h in v.split(",")]
return v
@lru_cache
def get_settings() -> AppSettings:
return AppSettings()
# Usage example
if __name__ == "__main__":
# For testing, pass values directly
settings = AppSettings(secret_key="my-secret-key-for-testing-only")
print(f"App: {settings.app_name} v{settings.version}")
print(f"DB URL: {settings.database.url}")
print(f"Redis URL: {settings.redis.url}")
print(f"Allowed hosts: {settings.allowed_hosts}")
print(f"Debug mode: {settings.debug}")
Output:
App: FastAPI App v1.0.0
DB URL: postgresql://postgres:@localhost:5432/app_db
Redis URL: redis://localhost:6379/0
Allowed hosts: ['localhost', '127.0.0.1']
Debug mode: False
This pattern brings several production benefits together: the @property methods on sub-models construct connection URLs from parts (no hardcoded connection strings), the field_validator lets ALLOWED_HOSTS=api.example.com,admin.example.com work as a comma-separated env var, and the nested DatabaseSettings with DATABASE__HOST-style env vars keeps related settings grouped and namespaced. Extending this for new services is as simple as adding another BaseModel sub-class and a field on AppSettings.
Frequently Asked Questions
What takes priority — environment variables or .env files?
Environment variables always win. The priority order from highest to lowest is: actual environment variables set in the shell or container, then .env file values, then field defaults defined in the model. This means you can commit a .env.example file with safe defaults to your repo and override specific values via real environment variables in production without modifying any files. The env_file setting is just a fallback source.
What happens when a required field is missing?
You get a pydantic_core.ValidationError immediately when Settings() is called, listing every missing field. This happens at startup, before your app serves any requests. The error message clearly names the field and explains that it is required — far better than the KeyError or None-related AttributeError you would get from raw os.environ calls. Use this behavior intentionally: import your settings at module level so the app crashes fast if misconfigured.
How do I override settings in tests?
Two clean approaches work well. First, if you use the @lru_cache singleton pattern, call get_settings.cache_clear() in your test setup and set environment variables before calling get_settings() again. Second, instantiate Settings() directly in tests with keyword arguments — these override all sources: Settings(database_url="sqlite:///test.db", debug=True). Using pytest-mock or monkeypatch.setenv() also works cleanly with pydantic-settings because it respects environment variables in the standard way.
Can I load settings from JSON or TOML instead of .env files?
pydantic-settings has a built-in JsonConfigSettingsSource for JSON files, available since version 2.x. For TOML, you can write a custom settings source by subclassing BaseSettings and overriding settings_customise_sources() to add your own loader. The custom source API is well-documented and lets you chain any number of sources together with explicit priority ordering, so mixing JSON config files with environment variable overrides is straightforward.
My .env file has secrets — should I commit it?
No. Add .env to your .gitignore immediately. Commit a .env.example file instead, with placeholder values and comments explaining what each variable does and how to get the real values. For shared development environments, use a secrets manager like 1Password Secrets Automation, AWS Secrets Manager, or Doppler, which can generate the .env file locally without any secrets being stored in the repo. In production, set environment variables directly in your container orchestration or PaaS platform and skip .env files entirely.
Are environment variable names case-sensitive?
By default, pydantic-settings is case-insensitive when matching environment variable names to field names. PORT, port, and Port will all match a field named port. You can change this by setting case_sensitive=True in SettingsConfigDict. On Windows, environment variables are always case-insensitive at the OS level regardless of this setting. Stick to uppercase by convention to avoid confusion across operating systems.
Conclusion
In this article, we covered pydantic-settings from the ground up: installing the library, defining typed settings models with BaseSettings, loading from .env files and environment variables, adding field constraints for validation, using nested models with env_nested_delimiter, handling secrets safely, and building a singleton config pattern with @lru_cache. The real-life example tied all of these patterns together in a FastAPI-ready configuration system.
The central benefit of this approach is that configuration errors surface at startup as clear ValidationErrors with every broken field listed, rather than failing silently at runtime. That one change — moving from scattered os.environ.get() calls to a single validated Settings class — makes misconfigured deployments immediately obvious and makes your configuration self-documenting through field types, defaults, and descriptions.
To go further, check out the official pydantic-settings documentation for advanced topics like custom settings sources, secrets rotation, and integration with cloud parameter stores.