pydantic-settings
pydantic-settings is the configuration layer of the Pydantic family: you declare a class with typed fields and it fills them from environment variables, .env files, secrets directories, CLI arguments, or cloud secret stores, then validates everything with Pydantic before your app starts. It is the old BaseSettings from Pydantic v1, split into its own package for v2. The payoff is failing at boot with a clear error when DATABASE_URL is missing or PORT is not an int, instead of failing at 3am when the code path finally runs.
If Pydantic is already in your stack, this is the obvious way to do configuration and there is little reason to hand-roll it. Outside the Pydantic world it is a heavy hammer for reading env vars, and dynaconf remains the better fit for elaborate multi-environment setups.
Use it if
- You are already on Pydantic v2 (FastAPI apps almost always are), so typed, validated settings cost one small extra package
- You want config as a typed object with IDE completion instead of os.environ.get() strings scattered through the codebase
- You layer sources: defaults in code, .env for local dev, real env vars in production, Docker secrets files in swarm/k8s, and you want documented precedence between them
- You need nested config (db.host, redis.url) mapped from flat env vars via a delimiter, which hand-rolled os.environ parsing gets wrong fast
- You are not otherwise using Pydantic: it drags in pydantic-core's compiled Rust wheels just to read a handful of env vars, where python-dotenv plus os.environ is a few kilobytes of pure Python
- Your config lives in complex env values: list and dict fields must arrive as JSON strings in the environment by default, and TAGS=a,b,c blowing up with a JSON parse error is this library's most famous surprise
- You need heavyweight multi-environment layering (dev/staging/prod trees, merged files, Vault-style dynamic reloads); dynaconf is built around exactly that
- You want config changes picked up at runtime: a Settings instance is a snapshot at construction, and re-reading sources means rebuilding it yourself
Setup reality
pip install pydantic-settings brings pydantic, pydantic-core, and python-dotenv along; .env support works out of the box. The learning curve is the sources model: init kwargs beat env vars, which beat .env values, which beat secrets files, and changing that order means overriding settings_customise_sources, a classmethod with a five-argument signature people mostly copy from the docs. Migrators from Pydantic v1 need the import moved from pydantic to pydantic_settings and Config classes rewritten as SettingsConfigDict. Extra inputs are ignored by default, but typos in env var names just silently leave defaults in place.
Patterns
Typed settings from environment variablesbasic-settings
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
port: int = 8000
debug: bool = False
settings = Settings()
print(settings.port)Matching is case-insensitive by default, so DATABASE_URL fills database_url. Missing required fields raise a ValidationError at construction, which is exactly the boot-time failure you want.
Read a .env file with env var overrideload-dotenv-file
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
)
api_key: str
debug: bool = Falsepython-dotenv is already a dependency, so no extra install. Real environment variables always beat .env values, which is what lets production env vars override the local file.
Namespace all variables with a prefixenv-prefix
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="MYAPP_")
host: str = "127.0.0.1"
port: int = 8000
# reads MYAPP_HOST and MYAPP_PORTThe prefix applies to every field and is invisible in code. If a field also sets validation_alias, the alias wins and the prefix is not applied to it, a detail that surprises people.
Nested models from flat env varsnested-settings
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class DbConfig(BaseModel):
host: str = "localhost"
port: int = 5432
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_nested_delimiter="__")
db: DbConfig = DbConfig()
# DB__HOST=db.prod.internal DB__PORT=6432Without env_nested_delimiter set, DB__HOST does nothing and the whole nested object must arrive as one JSON env var named DB. Pick the double underscore; single underscores collide with field names.
Complex types come from JSON stringslist-and-dict-fields
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
allowed_hosts: list[str] = []
feature_flags: dict[str, bool] = {}
# works: ALLOWED_HOSTS='["a.com", "b.com"]'
# fails: ALLOWED_HOSTS=a.com,b.com (json.decoder.JSONDecodeError)This is the library's number one gotcha: non-scalar fields are parsed as JSON. If you must accept comma-separated values, take the field as str and split it in a field_validator.
Read Docker/Kubernetes secrets filesdocker-secrets-dir
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(secrets_dir="/run/secrets")
db_password: str
# /run/secrets/db_password contains the valueOne file per field, file name matching the field name, file contents as the value. Env vars still take precedence over secrets files, so a stray DB_PASSWORD in the environment wins silently.
Map a field to differently named variablesfield-alias
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
redis_dsn: str = Field(
validation_alias=AliasChoices("REDIS_URL", "CACHE_URL"),
)AliasChoices tries names in order, which is how you accept a legacy variable name during a migration. Aliases are matched case-insensitively like everything else here.
Parse argv into settingscli-arguments
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(cli_parse_args=True)
verbose: bool = False
workers: int = 1
settings = Settings()
# python app.py --verbose true --workers 4Added in the 2.2+ era and now a full subcommand-capable CLI layer. Nested models become dotted flags like --db.host. CLI values sit above env vars in the default precedence.
Change which source winscustomise-source-order
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
class Settings(BaseSettings):
api_key: str = ""
@classmethod
def settings_customise_sources(
cls, settings_cls, init_settings, env_settings,
dotenv_settings, file_secret_settings,
):
# make .env beat real env vars (default is the reverse)
return (init_settings, dotenv_settings, env_settings, file_secret_settings)Earlier in the returned tuple means higher priority. This same hook is where you insert custom sources such as the built-in TOML, YAML, or cloud secret manager sources.
Deterministic settings in testsoverride-in-tests
def test_handler(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite://")
settings = Settings(_env_file=None, debug=True)
assert settings.debug is TrueInit kwargs outrank every other source, so passing values directly is the cleanest override. _env_file=None stops a developer's local .env from leaking into CI test runs.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| python-dotenv | PyPI | You just need .env files loaded into os.environ with zero opinions and zero heavy dependencies. |
| dynaconf | PyPI | Multi-environment layered config with toml/yaml files, merging, and Vault/Redis backends is the actual requirement. |
| environs | PyPI | You want typed env parsing with validation but prefer a small marshmallow-based library over the Pydantic stack. |
| python-decouple | PyPI | Django-style projects that want strict separation of settings from code with a tiny, stable API. |