Read Time: 6 minute(s)
Introduction
Environment variables are the backbone of clean, configurable applications. Whether you’re building a web API, a data pipeline, or a CLI tool, hardcoding secrets or environment-specific values is a bad idea. Luckily, Python offers many solutions—like os.environ
or .env
files with python-dotenv
. But if you’re using Pydantic (especially with FastAPI), there’s a better option: Pydantic Settings.
This tool provides a type-safe, declarative, and scalable way to manage configurations. Let’s explore how to use it effectively.
Table of Contents
1. Why Pydantic Settings?
Traditionally, managing configs with python-dotenv
and os.environ
looks like this:
# config.py
from dotenv import load_dotenv
import os
load_dotenv()
APP_NAME = os.getenv("APP_NAME", "My App")
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
raise ValueError("DATABASE_URL is required")
While this works, it’s manual and messy:
- You must cast types yourself (e.g.,
DEBUG
from string to bool). - Defaults require extra code.
- Validation isn’t built-in.
- Configs scatter across variables.
- Maintenance gets harder as projects grow.
Compare that to Pydantic Settings:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "My App"
debug: bool = False
database_url: str
settings = Settings()
Here’s what you gain:
- Automatic type casting (e.g., strings → bool).
- Built-in validation for missing values.
- A single schema for all configs.
- Less repetitive code.
In short, Pydantic Settings scales better than manual approaches..
2. Installation
Getting started is simple:
pip install pydantic-settings
Note: pydantic-settings
requires pydantic
as a dependency.
3. Basic Usage of Pydantic Settings
When your model inherits from BaseSettings
, it automatically pulls missing values from environment variables. For example:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "My App"
debug: bool = False
database_url: str
print(Settings().model_dump())
If DATABASE_URL
is set in your environment, it’s used. Otherwise, the default applies (or raises an error if required).
4. Dotenv (.env) Support
Pydantic works seamlessly with .env
files. You can load them in two ways:
Option 1: Model Config
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
Option 2: Runtime Load
settings = Settings(_env_file="prod.env", _env_file_encoding="utf-8")
5. Customizing Variable Names
By default, environment variables match field names. To customize:
Add a Prefix
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="my_prefix_")
database_url: str # Reads from `my_prefix_database_url`
Use Aliases
from pydantic import Field
class Settings(BaseSettings):
api_key: str = Field(alias="CUSTOM_API_KEY") # Reads from `CUSTOM_API_KEY`
6. Case Sensitivity
By default, variable names are case-insensitive. To enforce sensitivity:
class Settings(BaseSettings):
model_config = SettingsConfigDict(case_sensitive=True)
database_url: str # Only reads `database_url`, not `DATABASE_URL`
7. Parsing Environment Variables
Pydantic Settings shines when handling complex data types. By default, environment variables are parsed as-is, but you can customize this behavior for advanced use cases.
Handling Empty Values
To ignore empty environment variables and fall back to defaults, set env_ignore_empty=True
:
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_ignore_empty=True)
Simple vs. Complex Types
- Simple types (like
int
,str
,bool
) are parsed directly. - Complex types (like
list
,dict
, or nested models) require JSON strings or structured variables.
For example, this environment variable:
export SUB_MODEL='{"v1": "json-1", "v2": "json-2"}'
Becomes a Python dict when loaded:
sub_model: dict # Parsed as {'v1': 'json-1', 'v2': 'json-2'}
Pydantic maps these to:
{
"sub_model": {
"v2": "nested-2",
"deep": {"v4": "v4"}
}
}
Full Example
Here’s how to load nested settings:
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class DeepSubModel(BaseModel):
v4: str
class SubModel(BaseModel):
v1: str
v2: bytes
v3: int
deep: DeepSubModel
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_nested_delimiter="__")
v0: str
sub_model: SubModel
settings = Settings()
print(settings.model_dump())
Output:
{
"v0": "0",
"sub_model": {
"v1": "json-1",
"v2": "nested-2",
"v3": 3,
"deep": {"v4": "v4"}
}
}
Conclusion
Pydantic Settings transforms environment variables from loose strings into structured, validated configuration. Here’s why it matters:
- Safety First: Type hints and validation catch errors early, preventing runtime surprises.
- Scalability: Declarative classes grow with your app—no more scattered
os.getenv()
calls. - Developer Experience: Automatic parsing, nested data support, and
.env
integration just work.
For modern Python apps, this isn’t just convenient—it’s responsible engineering. Stop guessing at configs; let Pydantic handle the details while you focus on building.