Fix: Pydantic Settings Not Working — Env Vars Not Loading, Nested Config, and v2 Migration
Quick Answer
How to fix Pydantic Settings errors — environment variables not picked up, .env file not loaded, ValidationError missing field, nested model env vars, SettingsConfigDict required, secret files, and BaseSettings import.
The Error
You define settings but env vars don’t override defaults:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "sqlite:///dev.db"
# In shell: export DATABASE_URL=postgresql://prod...
settings = Settings()
print(settings.database_url)
# sqlite:///dev.db — env var ignoredOr .env file loads but values come out wrong:
# .env
LOG_LEVEL=DEBUG
WORKERS=4settings = Settings()
print(settings.workers)
# Error: Input should be a valid integer, unable to parse string as an integerOr you migrate from Pydantic v1 and imports break:
from pydantic import BaseSettings # ImportError in Pydantic v2
# BaseSettings was moved to pydantic-settings packageOr nested models don’t get their fields from env vars:
class DatabaseConfig(BaseModel):
host: str
port: int
class Settings(BaseSettings):
db: DatabaseConfig
# How do I set db.host and db.port via env vars?
# DB_HOST=localhost? DB__HOST=localhost? Both fail.Or Field(env=...) from v1 stops working:
class Settings(BaseSettings):
api_key: str = Field(env="MYAPP_API_KEY")
# DeprecationWarning or just silently ignored in v2Pydantic Settings is the configuration management library that replaces older patterns (python-decouple, environs, dynaconf) for Pydantic-based apps. In Pydantic v2 (2023), BaseSettings moved out of the main pydantic package into a separate pydantic-settings package, breaking countless apps. The new SettingsConfigDict pattern replaced the v1 Config inner class. This guide covers each common failure mode.
Why This Happens
Pydantic Settings loads configuration from multiple sources in priority order: init keyword args, environment variables, .env files, secret files, default values. The order is configurable via SettingsConfigDict.settings_sources. When env vars don’t override defaults, it’s usually because the source is missing, the prefix is wrong, or env_file isn’t loaded.
Pydantic v2’s split into pydantic (core models) and pydantic-settings (env-based settings) is intentional — Settings has different concerns than data models. But every v1 tutorial that imports BaseSettings from pydantic now breaks.
Fix 1: Pydantic v1 → v2 Migration
# OLD — Pydantic v1
from pydantic import BaseSettings, Field
class Settings(BaseSettings):
api_key: str = Field(..., env="API_KEY")
class Config:
env_file = ".env"
env_prefix = "MYAPP_"
# NEW — Pydantic v2
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
class Settings(BaseSettings):
api_key: str
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="MYAPP_",
)Install the new package:
pip install pydantic-settingsMigration checklist:
| v1 | v2 |
|---|---|
from pydantic import BaseSettings | from pydantic_settings import BaseSettings |
class Config: | model_config = SettingsConfigDict(...) |
Field(env="KEY") | Field doesn’t need env name; uses field name |
Field(env=["A", "B"]) | Field(validation_alias=AliasChoices("A", "B")) |
parse_env_var() | Custom source class |
Common Mistake: Trying to upgrade only Pydantic without installing pydantic-settings. The error message is clear (cannot import name 'BaseSettings'), but countless people miss the new package and assume v2 broke their settings entirely. Always pip install pydantic-settings when migrating.
Fix 2: Environment Variable Loading
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str = "sqlite:///dev.db"
log_level: str = "INFO"
workers: int = 4
model_config = SettingsConfigDict(
env_prefix="", # No prefix — DATABASE_URL maps to database_url
case_sensitive=False, # Default; DATABASE_URL == database_url
)
# Shell: export DATABASE_URL=postgresql://prod/db
settings = Settings()
print(settings.database_url) # postgresql://prod/dbField name → env var mapping:
field_name→FIELD_NAME(uppercased by default)- With prefix
MYAPP_→MYAPP_FIELD_NAME case_sensitive=Truerequires exact match
Custom env var name for a specific field:
from pydantic import Field
class Settings(BaseSettings):
api_key: str = Field(alias="MYAPP_API_KEY")Multiple env var aliases:
from pydantic import Field, AliasChoices
class Settings(BaseSettings):
api_key: str = Field(validation_alias=AliasChoices("API_KEY", "MYAPP_API_KEY", "KEY"))
# Checks API_KEY first, then MYAPP_API_KEY, then KEYDebug what’s being loaded:
settings = Settings()
print(settings.model_dump())
# Shows all resolved valuesFix 3: .env File Loading
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str
redis_url: str
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
)# .env
DATABASE_URL=postgresql://user:pass@localhost/db
REDIS_URL=redis://localhost:6379Install python-dotenv (required for .env loading):
pip install pydantic-settings[dotenv]
# Or
pip install python-dotenvMultiple env files with fallback:
model_config = SettingsConfigDict(
env_file=(".env", ".env.local"), # .env.local overrides .env
extra="ignore", # Ignore extra fields in .env not declared in Settings
)Environment-specific files:
import os
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=f".env.{os.getenv('ENV', 'dev')}",
)
# ENV=production python app.py → loads .env.productionPro Tip: Use .env files for local development only — never commit them. Production should set env vars directly via the deployment platform (Docker, Kubernetes, systemd). Add .env to .gitignore and provide a .env.example showing required variables without secret values.
# .env.example (committed)
DATABASE_URL=postgresql://user:pass@host/db
API_KEY=your-key-here
# .env (NOT committed)
DATABASE_URL=postgresql://real-user:real-pass@real-host/real-db
API_KEY=sk-actual-secretFix 4: Type Coercion and Complex Types
Pydantic Settings parses env var strings into the declared types. Some types require specific formats:
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
from typing import List
class Settings(BaseSettings):
# Integer
workers: int = 4
# Float
timeout: float = 30.0
# Boolean — accepts "true"/"false"/"1"/"0"/"yes"/"no"
debug: bool = False
# List — comma-separated by default
allowed_hosts: List[str] = []
# JSON for complex types
cors_origins: List[str] = []
model_config = SettingsConfigDict(env_file=".env")# .env
WORKERS=8
TIMEOUT=60.5
DEBUG=true
ALLOWED_HOSTS=["host1.com","host2.com"] # JSON array
# Or
ALLOWED_HOSTS=host1.com,host2.com # Requires custom parserCommon Mistake: Setting WORKERS=8 (with trailing space). Pydantic strips quotes but not whitespace — int("8 ") succeeds in Python but int("8 abc") fails. Always trim values in your .env file.
Lists from comma-separated strings:
from pydantic import field_validator
class Settings(BaseSettings):
allowed_hosts: List[str] = []
@field_validator("allowed_hosts", mode="before")
@classmethod
def parse_hosts(cls, v):
if isinstance(v, str):
return [h.strip() for h in v.split(",") if h.strip()]
return vNow ALLOWED_HOSTS=host1.com, host2.com, host3.com works.
JSON for dicts and lists (built-in):
# .env
CONFIG={"timeout": 30, "retries": 3}
HEADERS={"X-API-Key": "secret"}class Settings(BaseSettings):
config: dict
headers: dict[str, str]Fix 5: Nested Models with env_nested_delimiter
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseConfig(BaseModel):
host: str
port: int = 5432
user: str
password: str
class RedisConfig(BaseModel):
host: str = "localhost"
port: int = 6379
class Settings(BaseSettings):
db: DatabaseConfig
redis: RedisConfig
model_config = SettingsConfigDict(
env_nested_delimiter="__", # Use __ between model name and field
)Env vars for nested fields:
DB__HOST=prod-db.example.com
DB__PORT=5432
DB__USER=app_user
DB__PASSWORD=secret
REDIS__HOST=redis.example.comOr pass as JSON for the whole nested model:
DB={"host": "prod-db", "port": 5432, "user": "app", "password": "secret"}env_nested_delimiter choice — __ is the convention because single underscore conflicts with field names containing underscores:
# WRONG — ambiguous: is `db_host` a field, or `db.host`?
class Settings(BaseSettings):
db: DatabaseConfig
db_host: str # Conflicts!
model_config = SettingsConfigDict(env_nested_delimiter="_")
# CORRECT — double underscore avoids ambiguity
class Settings(BaseSettings):
db: DatabaseConfig
db_host: str
model_config = SettingsConfigDict(env_nested_delimiter="__")
# DB__HOST → db.host
# DB_HOST → db_host (top-level field)Fix 6: Secret Files (Docker Secrets, Kubernetes)
class Settings(BaseSettings):
api_key: str
db_password: str
model_config = SettingsConfigDict(
secrets_dir="/run/secrets",
)# /run/secrets/api_key contains: sk-actual-key
# /run/secrets/db_password contains: secret123Pydantic Settings reads each file (named after the field) as the value. Used by Docker Compose secrets and Kubernetes mounted secrets.
Docker Compose example:
services:
app:
image: myapp:latest
secrets:
- api_key
- db_password
secrets:
api_key:
file: ./secrets/api_key.txt
db_password:
file: ./secrets/db_password.txtKubernetes mounted secrets:
volumes:
- name: app-secrets
secret:
secretName: app-secrets
containers:
- name: app
volumeMounts:
- name: app-secrets
mountPath: /run/secrets
readOnly: trueEach key in the Kubernetes secret becomes a file at /run/secrets/<key>.
Priority — secrets files are loaded with lower priority than env vars by default. Override with custom sources if needed.
Fix 7: Custom Settings Sources
from pydantic_settings import (
BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict,
)
from typing import Tuple, Type
class VaultSettingsSource(PydanticBaseSettingsSource):
"""Load settings from HashiCorp Vault."""
def __init__(self, settings_cls):
super().__init__(settings_cls)
self.vault_data = self._load_from_vault()
def _load_from_vault(self):
import hvac
client = hvac.Client(url="https://vault.example.com", token="...")
return client.secrets.kv.read_secret_version(path="myapp")["data"]["data"]
def get_field_value(self, field, field_name):
return self.vault_data.get(field_name), field_name, False
def __call__(self):
return {k: self.vault_data[k] for k in self.vault_data}
class Settings(BaseSettings):
api_key: str
db_password: str
@classmethod
def settings_customise_sources(
cls,
settings_cls: Type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> Tuple[PydanticBaseSettingsSource, ...]:
return (
init_settings,
VaultSettingsSource(settings_cls),
env_settings,
dotenv_settings,
file_secret_settings,
)The order of returned sources determines priority — earlier sources win.
Common Mistake: Confusing source order. The default is init > env > dotenv > secrets. Beginners assume .env overrides env vars, but it’s the opposite — env vars win. To make .env override, swap their order in settings_customise_sources.
Fix 8: Multiple Environments (dev/staging/prod)
from pydantic_settings import BaseSettings, SettingsConfigDict
import os
class BaseAppSettings(BaseSettings):
app_name: str = "myapp"
debug: bool = False
database_url: str
model_config = SettingsConfigDict(extra="ignore")
class DevSettings(BaseAppSettings):
debug: bool = True
database_url: str = "sqlite:///dev.db"
model_config = SettingsConfigDict(env_file=".env.dev", extra="ignore")
class ProdSettings(BaseAppSettings):
debug: bool = False
model_config = SettingsConfigDict(env_file=".env.prod", extra="ignore")
def get_settings() -> BaseAppSettings:
env = os.getenv("APP_ENV", "dev")
if env == "production":
return ProdSettings()
return DevSettings()
settings = get_settings()Singleton pattern — usually want one Settings instance per process:
from functools import lru_cache
@lru_cache
def get_settings() -> Settings:
return Settings()
# Usage
settings = get_settings() # Created once, cachedFor FastAPI integration, use Depends:
from fastapi import Depends, FastAPI
from functools import lru_cache
@lru_cache
def get_settings():
return Settings()
app = FastAPI()
@app.get("/info")
def info(settings: Settings = Depends(get_settings)):
return {"app": settings.app_name}For FastAPI dependency injection patterns that pair with Pydantic Settings, see FastAPI dependency injection error.
Still Not Working?
Pydantic Settings vs Alternatives
- Pydantic Settings — Tight Pydantic integration, best when you’re already using Pydantic for models.
- environs — Simpler, env-only, lightweight.
- python-decouple — Old standard, no validation.
- dynaconf — Multi-format (env, YAML, TOML, INI), heavy but powerful.
Use Pydantic Settings for new FastAPI/Pydantic-heavy projects. The validation, IDE support, and model_dump for debugging make it the clear winner when you’re already in the Pydantic ecosystem.
Testing with Overrides
def test_with_custom_settings():
settings = Settings(database_url="sqlite:///:memory:")
assert settings.database_url == "sqlite:///:memory:"
# Or via environment variable injection
import os
def test_via_env(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite:///test.db")
settings = Settings()
assert settings.database_url == "sqlite:///test.db"For pytest fixture patterns with environment manipulation, see pytest fixture not found.
Sensitive Logging
When debugging, settings.model_dump() prints all values — including secrets. Use SecretStr for sensitive fields:
from pydantic import SecretStr
class Settings(BaseSettings):
api_key: SecretStr
settings = Settings()
print(settings.api_key) # SecretStr('**********')
print(settings.api_key.get_secret_value()) # Actual valueSecretStr masks the value in logs and error messages — accidental print(settings) no longer leaks the key.
Integration with Click and Typer
import click
from functools import lru_cache
@lru_cache
def get_settings() -> Settings:
return Settings()
@click.command()
@click.option("--env", default="dev")
def deploy(env):
settings = get_settings()
click.echo(f"Deploying {settings.app_name} to {env}")For Click-specific patterns, see Click not working. For Typer, see Typer not working.
Strict vs Lenient Validation
By default, Pydantic Settings raises on extra fields in env vars / .env files. Tolerate them:
class Settings(BaseSettings):
api_key: str
model_config = SettingsConfigDict(
extra="ignore", # ignore | forbid | allow
env_file=".env",
)"ignore"(recommended for production): Silently drop unknown fields. Lets you share.envacross multiple services with overlapping vars."forbid": RaiseValidationErroron unknown fields. Use during development to catch typos."allow": Store unknown fields as attributes (rarely useful for settings).
Field Aliases for Legacy Migration
When renaming a settings field but maintaining backward compat:
from pydantic import Field, AliasChoices
class Settings(BaseSettings):
db_url: str = Field(
validation_alias=AliasChoices("DB_URL", "DATABASE_URL", "POSTGRES_URL"),
)Old deployments with DATABASE_URL still work; new deployments can use DB_URL. Gives you a grace period for env var migration without breaking running services.
Loading from YAML/TOML
Pydantic Settings doesn’t include YAML/TOML loaders by default. Custom source:
import yaml
from pathlib import Path
from pydantic_settings import PydanticBaseSettingsSource
class YamlSettingsSource(PydanticBaseSettingsSource):
def __init__(self, settings_cls, yaml_path):
super().__init__(settings_cls)
self.data = yaml.safe_load(Path(yaml_path).read_text())
def get_field_value(self, field, field_name):
return self.data.get(field_name), field_name, False
def __call__(self):
return self.dataThen include YamlSettingsSource(settings_cls, "config.yaml") in settings_customise_sources().
For Loguru-based logging that reads its config from Pydantic Settings, see Loguru not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: SQLModel Not Working — table=True Confusion, Relationship Loading, and Session Errors
How to fix SQLModel errors — table not created without table=True, relationship not eager-loaded MissingGreenlet, AttributeError on lazy attribute, mixing Pydantic and Table classes, Optional vs default None, and async session setup.
Fix: Pydantic ValidationError — Field Required, Value Not a Valid Type, or Extra Fields
How to fix Pydantic v2 validation errors — required fields, type coercion, model_validator, custom validators, extra fields config, and migrating from Pydantic v1.
Fix: Pydantic ValidationError — Field Required / Value Not Valid
How to fix Pydantic ValidationError in Python — missing required fields, wrong types, custom validators, handling optional fields, v1 vs v2 API differences, and debugging complex nested models.
Fix: msgspec Not Working — Struct Definition, Type Validation, and JSON/MessagePack Encoding
How to fix msgspec errors — Struct field type not supported, ValidationError on decode, msgspec vs Pydantic differences, custom type hooks, frozen Struct mutation, and JSON Schema generation.