Skip to content

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

FixDevs · (Updated: )

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

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: true

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

GCP — 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 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 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 .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.

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 Settings in a property that re-instantiates on access (loses lru_cache benefits, 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.

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