Configuration with Pydantic: Settings and Environment
Configuration management is the bridge between development and production. Applications need different settings per environment: database URLs, API keys, logging levels, feature flags. Pydantic's BaseSettings class takes environment variables and .env files and validates them using the same validation engine as models. No more parsing environment strings manually, no more type errors from forgotten conversions, no more secrets in git. This article covers loading, validating, and managing configuration like a pro.
What Is BaseSettings?
BaseSettings is a subclass of BaseModel that reads from environment variables by default. Declare your configuration as a model, and Pydantic automatically loads values from os.environ:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "MyApp"
debug: bool = False
database_url: str
secret_key: str
max_connections: int = 10
# Pydantic automatically reads from environment
settings = Settings()
# Reads: DB_URL from $DATABASE_URL, SECRET_KEY from $SECRET_KEY, etc.
When you instantiate Settings(), Pydantic:
- Looks for environment variables matching field names (case-insensitive, with optional prefix).
- Type-converts and validates each variable.
- Raises
ValidationErrorif any required variable is missing or invalid.
Loading from .env Files
For local development, store configuration in a .env file:
APP_NAME=MyApp
DEBUG=true
DATABASE_URL=postgresql://localhost/mydb
SECRET_KEY=dev-secret-12345
MAX_CONNECTIONS=20
Load the .env file via BaseSettings:
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
secret_key: str
max_connections: int = 10
settings = Settings()
print(settings.database_url) # "postgresql://localhost/mydb"
print(settings.debug) # True (coerced from string)
Environment variables override .env values, so you can override settings at runtime:
DATABASE_URL=postgresql://prod-server/db python app.py
Field Names and Prefixes
By default, Pydantic matches field names to env vars (case-insensitive). You can customize this:
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="APP_" # Expect APP_DATABASE_URL, APP_SECRET_KEY, etc.
)
database_url: str = Field(validation_alias="DB_URL") # Also accept $DB_URL
secret_key: str
debug: bool = False
# Reads from $APP_DATABASE_URL or $DB_URL, $APP_SECRET_KEY, $APP_DEBUG
settings = Settings()
- env_prefix: Prepend a prefix to all env var names (useful for multi-app configs).
- validation_alias: Accept an alternative env var name.
Validation and Type Coercion
Settings are validated like models. Type coercion and constraints work:
from pydantic_settings import BaseSettings
from pydantic import Field
class Settings(BaseSettings):
# Port must be between 1 and 65535
port: int = Field(ge=1, le=65535, default=8000)
# Timeout as float, must be positive
timeout: float = Field(gt=0.0, default=30.0)
# Workers: at least 1
workers: int = Field(ge=1, default=4)
# Env: PORT=8080, TIMEOUT=45.5, WORKERS=8
settings = Settings()
print(settings.port) # 8080
print(settings.timeout) # 45.5
print(settings.workers) # 8
# Invalid: PORT=-1 raises ValidationError
# Invalid: TIMEOUT=invalid raises ValidationError (not a float)
Pydantic catches configuration errors at startup, before your app runs.
List and Dict Settings
Complex configuration structures:
from pydantic_settings import BaseSettings
from typing import list
class Settings(BaseSettings):
# Comma-separated list
allowed_hosts: list[str] = ["localhost", "127.0.0.1"]
# Key=value pairs (requires custom parsing)
log_config: dict[str, str] = {}
# Env: ALLOWED_HOSTS=example.com,api.example.com
settings = Settings()
print(settings.allowed_hosts) # ["example.com", "api.example.com"]
For complex lists, use JSON in environment:
import json
from pydantic_settings import BaseSettings
from typing import list
# Env: FEATURES='["auth", "api", "dashboard"]'
class Settings(BaseSettings):
features: list[str]
@field_validator("features", mode="before")
@classmethod
def parse_features(cls, v):
if isinstance(v, str):
return json.loads(v)
return v
settings = Settings()
print(settings.features) # ["auth", "api", "dashboard"]
Nested Settings
Settings can have nested models:
from pydantic_settings import BaseSettings
from pydantic import BaseModel
class DatabaseConfig(BaseModel):
host: str
port: int = 5432
username: str
password: str
database: str
class CacheConfig(BaseModel):
backend: str = "redis"
ttl: int = 3600
class Settings(BaseSettings):
database: DatabaseConfig
cache: CacheConfig
debug: bool = False
# Env: DATABASE_HOST=localhost, DATABASE_PASSWORD=secret, CACHE_TTL=7200
settings = Settings(
database={
"host": "localhost",
"username": "user",
"password": "secret",
"database": "mydb"
},
cache={
"backend": "redis",
"ttl": 7200
}
)
Or use env vars with nested field access:
# Env: DATABASE__HOST=localhost (double underscore for nesting)
# This requires model_config with env_nested_delimiter
Production Example: Complete App Settings
Here's a realistic production configuration:
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, EmailStr
from typing import Optional
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_prefix="APP_",
case_sensitive=False
)
# Core app config
app_name: str = "MyAPI"
debug: bool = False
environment: str = Field(default="development")
# Server config
host: str = "0.0.0.0"
port: int = Field(ge=1, le=65535, default=8000)
# Database config
database_url: str = Field(
..., # Required
validation_alias="DB_URL"
)
database_pool_size: int = Field(default=10, ge=1)
database_echo: bool = False
# Security
secret_key: str = Field(
...,
validation_alias="SECRET_KEY"
)
allowed_hosts: list[str] = Field(default=["localhost"])
cors_origins: list[str] = Field(default=["http://localhost:3000"])
# API config
api_timeout: float = Field(default=30.0, gt=0)
max_request_size: int = Field(default=1_000_000, ge=100)
# Logging
log_level: str = Field(default="INFO")
# Optional integrations
sentry_dsn: Optional[str] = None
stripe_api_key: Optional[str] = None
# Usage
settings = Settings()
# Access configuration
if settings.debug:
print(f"Running {settings.app_name} in debug mode")
# Use in app initialization
from sqlalchemy import create_engine
engine = create_engine(settings.database_url, pool_size=settings.database_pool_size)
Example .env file:
APP_DEBUG=true
APP_ENVIRONMENT=development
APP_HOST=127.0.0.1
APP_PORT=8000
APP_DATABASE_URL=postgresql://user:pass@localhost/mydb
APP_DB_URL=postgresql://user:pass@localhost/mydb
APP_SECRET_KEY=dev-secret-change-in-production
APP_ALLOWED_HOSTS=localhost,127.0.0.1,api.local
APP_CORS_ORIGINS=http://localhost:3000,http://localhost:5173
APP_LOG_LEVEL=DEBUG
Configuration Validation
Validate configuration at startup with custom validators:
from pydantic_settings import BaseSettings
from pydantic import field_validator
class Settings(BaseSettings):
environment: str
debug: bool = False
@field_validator("debug")
@classmethod
def validate_debug(cls, v, info):
env = info.data.get("environment")
if env == "production" and v:
raise ValueError("debug=True is not allowed in production")
return v
# Fails if ENVIRONMENT=production and DEBUG=true
try:
settings = Settings()
except Exception as e:
print("Configuration error:", e)
Catch config errors before your app starts, preventing silent misconfigurations.
Key Takeaways
BaseSettingsautomatically reads environment variables and validates them as a model.- Load
.envfiles withenv_file=".env"inSettingsConfigDict. - Environment variables override
.envvalues; useenv_prefixfor consistency. - Type coercion and field constraints work on settings the same way as models.
- Use validators to enforce cross-field rules (e.g., no debug mode in production).
- Nested models represent complex configuration hierarchies.
Frequently Asked Questions
How do I handle secrets safely in production?
Never commit .env files to git. Use environment variables set by your deployment platform (Docker, Kubernetes, cloud provider). Pydantic only reads values from os.environ, not from source code.
Can I use different settings per environment?
Yes. Load different .env files based on APP_ENV: env_file=f".env.{os.getenv('APP_ENV', 'dev')}". Or use separate settings classes per environment and instantiate the right one.
How do I override settings at runtime for testing?
Use Pydantic's Settings as a context or pass values explicitly:
@pytest.fixture
def test_settings():
return Settings(
database_url="sqlite:///:memory:",
debug=True,
secret_key="test-key"
)
What if an env var is not set and has no default?
Pydantic raises ValidationError with a missing error. Required settings fail loudly at startup.
Can I reload settings at runtime?
Settings instances are static. For dynamic configuration (feature flags), use a separate config service or cache layer, not BaseSettings.