Skip to main content

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:

  1. Looks for environment variables matching field names (case-insensitive, with optional prefix).
  2. Type-converts and validates each variable.
  3. Raises ValidationError if 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

  • BaseSettings automatically reads environment variables and validates them as a model.
  • Load .env files with env_file=".env" in SettingsConfigDict.
  • Environment variables override .env values; use env_prefix for 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.

Further Reading