A CLI's settings arrive from several places at once: defaults in the code, a config file, environment variables set in a shell or CI job, and flags on the command line. Merging them by hand means a precedence function, type conversion for every environment variable (they are all strings), validation, and error messages — repeated for every new setting. pydantic-settings does all of it from one class declaration: each field has a type, a default and constraints; the library reads environment variables and config files, converts and validates everything, merges sources in the order you choose, and reports every problem with the field it concerns. This guide builds a settings model for a CLI, wires in a TOML config file and command-line flags with the correct precedence, handles nested sections and secrets, and turns validation errors into messages users can act on. It belongs to the handling configuration files and environment variables topic.
Prerequisites
- Python 3.10+,
pydantic2 andpydantic-settings2.x (uv add pydantic-settings). - A Typer or Click CLI.
- Familiarity with the precedence idea from config precedence: flags, env, files and defaults.
Sources and precedence
pydantic-settings reads from a list of sources, highest priority first. Initialisation arguments — the values you pass to the constructor, which is where command-line flags go — come first; then environment variables; then optional dotenv and secret-file sources; and any config-file source you add. Field defaults apply only when no source provides a value. The order is entirely under your control through settings_customise_sources, which is how the recipe below puts a TOML file under the environment.
The recipe: the settings model
# src/mytool/settings.py
from __future__ import annotations
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field, HttpUrl, SecretStr, ValidationError
from pydantic_settings import (BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict,
TomlConfigSettingsSource)
CONFIG_FILE = Path.home() / ".config" / "mytool" / "config.toml"
class DeploySettings(BaseModel):
region: str = "eu-west-1"
replicas: int = Field(2, ge=1, le=50)
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="MYTOOL_",
env_nested_delimiter="__", # MYTOOL_DEPLOY__REGION=us-east-1
extra="forbid", # unknown keys in the file are errors
toml_file=CONFIG_FILE,
)
api_url: HttpUrl = HttpUrl("https://api.example.com")
timeout: float = Field(30.0, gt=0)
token: SecretStr | None = None
deploy: DeploySettings = DeploySettings()
@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, ...]:
# Highest priority first: flags (init), environment, then the TOML file.
return (init_settings, env_settings, TomlConfigSettingsSource(settings_cls))
class SettingsError(Exception):
pass
def load_settings(**flags: Any) -> Settings:
"""Merge flags (only those actually given), environment and config file."""
given = {k: v for k, v in flags.items() if v is not None}
try:
return Settings(**given)
except ValidationError as exc:
lines = []
for err in exc.errors():
where = ".".join(str(p) for p in err["loc"])
lines.append(f" {where}: {err['msg']}")
raise SettingsError("invalid settings:\n" + "\n".join(lines)) from None
What the configuration does:
env_prefix="MYTOOL_"mapstimeouttoMYTOOL_TIMEOUT, so your variables never clash with other tools'.env_nested_delimiter="__"lets environment variables reach nested sections:MYTOOL_DEPLOY__REGION=us-east-1setsdeploy.region.extra="forbid"turns unknown keys in the config file into errors, so a typo likereplicaz = 3is reported rather than silently ignored.toml_file=plusTomlConfigSettingsSourcereads the config file; listing it afterenv_settingsinsettings_customise_sourcesgives the environment priority over the file. Leaving outdotenv_settingsandfile_secret_settingsis deliberate — a CLI loading.envfrom whatever directory it runs in can be surprising in production.- Types do the conversion.
timeout: floatturns"20"from the environment into20.0;HttpUrlvalidates URLs;Field(gt=0)andField(ge=1, le=50)enforce ranges. SecretStrkeeps the token masked inrepr, logs andmodel_dump_json();settings.token.get_secret_value()is the explicit way to read it, as discussed in secrets and credentials in Python CLIs.
The recipe: flags on top
Command-line flags go in as constructor arguments — but only the ones the user actually gave. Passing None for an unset flag would override the environment and file with None, which is why load_settings filters them:
# src/mytool/cli.py
from __future__ import annotations
from typing import Annotated
import typer
from mytool.settings import SettingsError, load_settings
app = typer.Typer()
@app.callback()
def main(
ctx: typer.Context,
api_url: Annotated[str | None, typer.Option("--api-url", help="API base URL.")] = None,
timeout: Annotated[float | None, typer.Option(help="Request timeout in seconds.")] = None,
) -> None:
"""Deployment tool with typed settings."""
try:
ctx.obj = load_settings(api_url=api_url, timeout=timeout)
except SettingsError as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(78) # EX_CONFIG
@app.command()
def show(ctx: typer.Context) -> None:
"""Print the effective settings (secrets masked)."""
typer.echo(ctx.obj.model_dump_json(indent=2))
if __name__ == "__main__":
app()
Typer options default to None here precisely so "not given" can be told apart from a real value. The validated settings object is stored on the context once and read by every command, following the pattern in sharing state with Click context objects.
Turning validation errors into messages
pydantic raises one ValidationError containing every problem, each with a location (deploy.replicas) and a message. load_settings flattens that into a short list and the CLI exits with status 78 (EX_CONFIG from sysexits.h), which scripts can distinguish from runtime failures:
Reporting all problems together matters for configuration: users fix a config file in one pass rather than rerunning after each correction.
UX considerations
- Offer
config show. Printing the effective, merged settings — with secrets masked — answers "which value won?" instantly.model_dump_json()respectsSecretStr. - Document the environment variables. The field names plus the prefix and nested delimiter define them; generate a table from
Settings.model_fieldsfor your docs so it never drifts. - Keep import cost in mind. pydantic and pydantic-settings add noticeable import time. For commands where startup matters — completion,
--version— load settings lazily, only in commands that need them; see reducing CLI dependency weight. - Name the source in errors where you can. pydantic reports the field, not where its value came from; when a value is surprising, a
--verboseline listing which sources were read helps users find the culprit.
Testing the behaviour
Point the model at a temporary config file, clear the relevant environment variables, and exercise each source through the CLI:
# tests/test_settings.py
import json
import pytest
from typer.testing import CliRunner
from mytool import settings as settings_mod
from mytool.cli import app
runner = CliRunner()
@pytest.fixture(autouse=True)
def isolated(tmp_path, monkeypatch):
cfg = tmp_path / "config.toml"
monkeypatch.setitem(settings_mod.Settings.model_config, "toml_file", cfg)
for var in ("MYTOOL_API_URL", "MYTOOL_TIMEOUT", "MYTOOL_TOKEN", "MYTOOL_DEPLOY__REGION"):
monkeypatch.delenv(var, raising=False)
return cfg
def show(*args, env=None):
result = runner.invoke(app, [*args, "show"], env=env)
return result, (json.loads(result.output) if result.exit_code == 0 else None)
def test_defaults():
_, data = show()
assert data["timeout"] == 30.0 and data["deploy"]["replicas"] == 2
def test_precedence_flag_env_file(isolated):
isolated.write_text('timeout = 10\n[deploy]\nregion = "ap-south-1"\n')
_, data = show(env={"MYTOOL_TIMEOUT": "20"})
assert data["timeout"] == 20.0 and data["deploy"]["region"] == "ap-south-1"
_, data = show("--timeout", "5", env={"MYTOOL_TIMEOUT": "20"})
assert data["timeout"] == 5.0
def test_nested_env_var():
_, data = show(env={"MYTOOL_DEPLOY__REGION": "us-east-1"})
assert data["deploy"]["region"] == "us-east-1"
def test_secret_is_masked():
result, _ = show(env={"MYTOOL_TOKEN": "tok_supersecret"})
assert "tok_supersecret" not in result.output and "**********" in result.output
def test_all_errors_reported_together(isolated):
isolated.write_text('timeout = -1\nreplicaz = 3\n')
result, _ = show(env={"MYTOOL_API_URL": "not a url"})
assert result.exit_code == 78
for fragment in ("api_url", "timeout", "replicaz"):
assert fragment in result.output
The precedence test walks the whole chain in one place: file value, overridden by environment, overridden by flag. The fixture's monkeypatch.setitem on model_config redirects the TOML file for the duration of each test, so no test ever reads the developer's real config.
Conclusion
pydantic-settings turns a CLI's configuration into one declarative class: typed fields with defaults and constraints, an environment prefix and nested delimiter for variables, a TOML file source, and settings_customise_sources to put flags above environment above file. Pass only the flags the user actually gave, forbid unknown keys, keep secrets in SecretStr, report every validation problem at once with a configuration exit code, and test the precedence chain explicitly. Adding a setting is then a one-line change that brings validation, environment support and documentation with it.
Frequently asked questions
pydantic-settings or a hand-written loader?
For a handful of settings and a CLI where startup time is critical, a small hand-written loader such as the one in reading TOML config with tomllib avoids the dependency. Once settings multiply, nest or come from several sources, pydantic-settings saves more code than it costs.
Can it read settings from pyproject.toml?
Yes: PyprojectTomlConfigSettingsSource reads a [tool.mytool] table, which suits developer tools configured per project.
How do I support multiple profiles?
Make the config file hold a table per profile and select one before constructing Settings — for example by loading the chosen table into a dict passed as init values beneath the flags. The profile design itself is covered in supporting multiple profiles and accounts.
How do list or dict settings come from environment variables?
Complex fields — tags: list[str], a nested model — are parsed from environment variables as JSON: a top-level tags: list[str] field is set with MYTOOL_TAGS='["web","blue"]'. That is precise but awkward to type; for list settings people set often, also accept a comma-separated form with a field_validator(mode="before") that splits strings, and document both.
Do boolean environment variables work?
Yes. pydantic accepts 1, true, yes, on and their negatives, case-insensitively, for bool fields — so MYTOOL_VERBOSE=true and MYTOOL_VERBOSE=1 both work.