Fix: Pydantic Settings Not Working — Env Vars Not Loading, Nested Config, and v2 Migration
Part of: Python Errors
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.
Platform Differences and Deployment Targets
Pydantic Settings is the same library everywhere, but the source you load secrets from changes by platform. The BaseSettings model is portable; the loader chain isn’t. Pick the right backend for each environment.
Pydantic v1 vs v2 — different mental models. In v1, BaseSettings lived inside the main pydantic package and you configured it via an inner class Config. In v2, BaseSettings moved to pydantic-settings and config is model_config = SettingsConfigDict(...). The v2 split is permanent — there is no compatibility shim. Every v1 tutorial that imports BaseSettings from pydantic will break under v2 with ImportError: cannot import name 'BaseSettings'. Pin both: pydantic>=2,<3 and pydantic-settings>=2,<3. Mixing v1 and v2 within the same process is unsupported.
Local dev with .env. This is the simplest profile and the only one that should rely on env_file. Commit a .env.example showing required keys with placeholder values. Add real .env to .gitignore. Never read .env in production — env files were never designed to be deployed.
Docker — env vars only. Pass env vars via docker run -e or environment: in compose. Don’t bake secrets into the image. If you really need files, use Docker secrets:
services:
app:
image: myapp:latest
secrets:
- api_key
secrets:
api_key:
external: trueThen point secrets_dir="/run/secrets" in SettingsConfigDict. Docker mounts each secret as a file named after the secret. The model field name must match the filename exactly (case-sensitive on Linux).
Kubernetes — Secrets and ConfigMaps. Two patterns work cleanly. Mount the secret as a volume (one file per key) and set secrets_dir="/run/secrets". Or expose them as env vars via envFrom: secretRef:. Env vars are simpler but get logged in pod descriptions; mounted files don’t. For credentials, prefer mounted files.
AWS — SSM Parameter Store or Secrets Manager. Neither ships with Pydantic Settings out of the box. Write a custom PydanticBaseSettingsSource that calls boto3.client("ssm").get_parameters_by_path() once at startup and caches the result. Add it to settings_customise_sources() between init and env. Don’t fetch in __init__ of every request — Settings should be created once and reused.
class SSMSource(PydanticBaseSettingsSource):
def __init__(self, settings_cls):
super().__init__(settings_cls)
import boto3
ssm = boto3.client("ssm")
params = ssm.get_parameters_by_path(Path="/myapp/", WithDecryption=True)
self.data = {p["Name"].rsplit("/", 1)[-1]: p["Value"] for p in params["Parameters"]}
def get_field_value(self, field, field_name):
return self.data.get(field_name), field_name, False
def __call__(self):
return self.dataGCP — Secret Manager and Azure — Key Vault follow the same pattern: SDK client at startup, cache, return values via get_field_value. Wrap the SDK call in try/except so a transient network blip doesn’t crash the process at import time.
HashiCorp Vault. Use hvac with AppRole or Kubernetes auth — never static tokens. Refresh leases if you keep the process running for days; settings loaded at startup go stale otherwise. For rotating credentials, treat Settings as immutable and refresh by restarting the pod.
FastAPI integration. Always wrap Settings() in @lru_cache and inject via Depends(get_settings). Without the cache, every request rebuilds the model and re-parses env vars. With it, you get a single instance per process and tests can override via app.dependency_overrides[get_settings].
Multi-environment profiles. Pick one strategy and stick to it: either a single Settings class with APP_ENV switching env_file=f".env.{APP_ENV}", or subclasses (DevSettings, ProdSettings) selected by a factory. Don’t mix — subclasses give better defaults per env but break lru_cache typing; the single-class pattern is simpler but spreads env-specific logic across the .env files.
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 Typer-based CLI integration, 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.
Settings Refresh Without Process Restart
BaseSettings is read once at instantiation. There is no built-in “watch and reload” mechanism. If a .env file changes on disk or a secret rotates in Vault, the running process won’t notice. Three options:
- Restart the process on config change (cleanest for containers — orchestrators do this for you).
- Wrap
Settingsin a property that re-instantiates on access (loseslru_cachebenefits, fine for low-traffic admin endpoints). - Push config changes via a separate channel (Redis pubsub, file watcher) and call
Settings()again on the signal.
Don’t try to mutate fields on a live Settings instance — Pydantic v2 freezes model instances by default and silent writes to __dict__ bypass validation.
Case Sensitivity Surprises on Windows vs Linux
case_sensitive=False is the default, so DATABASE_URL and database_url both map to the same field. But the comparison happens against the field name, not the env var. If you set case_sensitive=True and run the same code on Windows (which treats env var names case-insensitively at the OS level), values may load that shouldn’t, or vice versa. Stick with the default False for cross-platform code. Only flip to True if you have field names that differ only in case (rare and not recommended).
Validating Required Secrets at Startup
A common production bug: the app boots, then crashes the first time a route is hit because API_KEY was missing. Validate eagerly by instantiating Settings() in your entrypoint before serving traffic:
def main():
try:
settings = Settings()
except ValidationError as e:
print(f"Config error: {e}", file=sys.stderr)
sys.exit(2)
run_server(settings)Pair this with extra="forbid" during development to catch typos in .env. Production should use extra="ignore" so shared env files don’t crash unrelated services.
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 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.
Fix: Tortoise ORM Not Working — Model Registration, Async Init, and Relationship Errors
How to fix Tortoise ORM errors — Tortoise.init not called, no module imported model, fetch_related missing, aerich migration setup, FastAPI integration patterns, and ConfigurationError missing connection.