Input & UX

Typed Settings with pydantic-settings in Python CLIs

Merge flags, environment variables and a TOML file into one validated settings object with pydantic-settings: precedence, nested env vars, secrets and errors.

Updated

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

Sources and precedence

Where pydantic-settings looks The precedence of settings sources in pydantic-settings for a CLI: initialisation arguments from flags, environment variables, dotenv files, a TOML file, then field defaults. Where pydantic-settings looks Init arguments highest values from command-line flags Environment env MYTOOL_API_URL, MYTOOL_TIMEOUT .env file (optional) dotenv local development only TOML config file file via TomlConfigSettingsSource Field defaults lowest declared on the model The order is configurable in settings_customise_sources; this one matches CLI conventions.

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

One model for every source A pydantic-settings model declaring typed fields, a nested section, a secret field, and the environment prefix. One model for every source class Settings(BaseSettings) env_prefix="MYTOOL_" api_url: HttpUrl validated URL timeout: float = 30 coerced from strings token: SecretStr masked in repr deploy: DeploySettings nested: MYTOOL_DEPLOY__REGION Validation errors name the field Types document the config SecretStr never prints itself The model is the documentation: names, types, defaults and constraints in one place.
# 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_" maps timeout to MYTOOL_TIMEOUT, so your variables never clash with other tools'.
  • env_nested_delimiter="__" lets environment variables reach nested sections: MYTOOL_DEPLOY__REGION=us-east-1 sets deploy.region.
  • extra="forbid" turns unknown keys in the config file into errors, so a typo like replicaz = 3 is reported rather than silently ignored.
  • toml_file= plus TomlConfigSettingsSource reads the config file; listing it after env_settings in settings_customise_sources gives the environment priority over the file. Leaving out dotenv_settings and file_secret_settings is deliberate — a CLI loading .env from whatever directory it runs in can be surprising in production.
  • Types do the conversion. timeout: float turns "20" from the environment into 20.0; HttpUrl validates URLs; Field(gt=0) and Field(ge=1, le=50) enforce ranges.
  • SecretStr keeps the token masked in repr, logs and model_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:

Validation errors from any source Terminal output of a CLI reporting pydantic-settings validation errors for an invalid URL from the environment and a negative timeout from a config file. Validation errors from any source bash $ MYTOOL_API_URL=not-a-url mytool status error: invalid settings: api_url: Input should be a valid URL, relative URL without a base timeout: Input should be greater than 0 (from mytool.toml) Reporting every problem at once saves a fix-one-rerun loop.

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() respects SecretStr.
  • Document the environment variables. The field names plus the prefix and nested delimiter define them; generate a table from Settings.model_fields for 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 --verbose line 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.