Skip to content

Fix: Pydantic Settings Not Working — Env Vars Not Loading, Nested Config, and v2 Migration

FixDevs ·

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 ignored

Or .env file loads but values come out wrong:

# .env
LOG_LEVEL=DEBUG
WORKERS=4
settings = Settings()
print(settings.workers)
# Error: Input should be a valid integer, unable to parse string as an integer

Or you migrate from Pydantic v1 and imports break:

from pydantic import BaseSettings   # ImportError in Pydantic v2
# BaseSettings was moved to pydantic-settings package

Or 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 v2

Pydantic 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-settings

Migration checklist:

v1v2
from pydantic import BaseSettingsfrom 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/db

Field name → env var mapping:

  • field_nameFIELD_NAME (uppercased by default)
  • With prefix MYAPP_MYAPP_FIELD_NAME
  • case_sensitive=True requires 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 KEY

Debug what’s being loaded:

settings = Settings()
print(settings.model_dump())
# Shows all resolved values

Fix 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:6379

Install python-dotenv (required for .env loading):

pip install pydantic-settings[dotenv]
# Or
pip install python-dotenv

Multiple 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.production

Pro 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-secret

Fix 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 parser

Common 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 v

Now 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.com

Or 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: secret123

Pydantic 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.txt

Kubernetes mounted secrets:

volumes:
  - name: app-secrets
    secret:
      secretName: app-secrets
containers:
  - name: app
    volumeMounts:
      - name: app-secrets
        mountPath: /run/secrets
        readOnly: true

Each 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, cached

For 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 value

SecretStr 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 .env across multiple services with overlapping vars.
  • "forbid": Raise ValidationError on 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.data

Then 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles