Robust validation turns brittle scripts into resilient tools. The core principle is simple: validate everything at the boundary, before a single line of business logic runs, so your command body can assume it is working with clean, typed data. This overview walks through schema-driven validation with Pydantic v2, custom validators and callbacks in Typer and Click, layering checks from type to range to cross-field, and converting validation failures into clean CLI errors with the right exit codes.
TL;DR
- Validate at the boundary: parse raw strings into a typed, validated model first; the rest of the command trusts that model.
- Use a Pydantic v2
BaseModelas the single source of truth for shape, types, ranges, and cross-field rules. - Wire it in with a Typer/Click callback or a custom
ParamType, catchValidationError, and re-raise as aclick.BadParameterso the user gets exit code 2 and a usage message. - Layer your checks: type coercion, then per-field constraints (
Field(ge=..., le=...)), then cross-field invariants (@model_validator).
Validate at the boundary
The most common validation mistake is scattering if checks through the command body. By the time you discover that --replicas is negative, you may have already opened a connection or written a file. Instead, treat the argument layer as a gate: nothing untrusted gets past it.
A Pydantic v2 model is the cleanest way to express that gate. It captures the entire contract — field names, types, bounds, defaults, and relationships — in one declarative place:
from typing import Annotated
from pydantic import BaseModel, Field, field_validator, model_validator
class Resources(BaseModel):
cpu: Annotated[int, Field(ge=1, le=64)]
memory_mb: Annotated[int, Field(ge=128)]
class DeployConfig(BaseModel):
name: Annotated[str, Field(min_length=1, max_length=63)]
replicas: Annotated[int, Field(ge=1, le=100)]
resources: Resources
canary_percent: Annotated[int, Field(ge=0, le=100)] = 0
@field_validator("name")
@classmethod
def name_is_dns_safe(cls, v: str) -> str:
if not all(c.isalnum() or c == "-" for c in v):
raise ValueError("name must contain only alphanumerics and hyphens")
return v.lower()
@model_validator(mode="after")
def canary_needs_replicas(self) -> "DeployConfig":
if self.canary_percent > 0 and self.replicas < 2:
raise ValueError("canary_percent requires at least 2 replicas")
return self
Call DeployConfig.model_validate(data) once, and every layer fires in order.
Layered validation: type, range, cross-field
Good validation is layered, and Pydantic runs the layers for you in a predictable sequence:
- Type coercion — Pydantic parses
"4"into4for anintfield, or rejects"four". This is the cheapest, broadest layer. - Per-field constraints —
Field(ge=1, le=64)andfield_validatorenforce bounds and shape on individual values. Thename_is_dns_safevalidator both checks and normalizes (lowercasing), which is a useful trick: validators can return a cleaned value. - Cross-field invariants —
@model_validator(mode="after")sees the fully built object, so it can assert relationships between fields, like "canary deployments need at least two replicas." These rules are impossible to express on a single field.
Layering matters because earlier layers protect later ones: a model_validator never has to guard against replicas being a string, because the type layer already guaranteed it is an int.
Wiring validators into Typer and Click
In Typer, a callback on an option runs your parsing function. Raise typer.BadParameter to produce a clean usage error:
import typer
from pydantic import ValidationError
def parse_replicas(value: int) -> int:
if value > 50:
raise typer.BadParameter("replicas above 50 require sign-off")
return value
@app.command()
def deploy(replicas: int = typer.Option(..., callback=parse_replicas)):
...
In Click, subclass click.ParamType and override convert; call self.fail(...) on bad input. That is the natural home for parsing structured payloads — covered in depth in parsing nested JSON args in Python CLIs, which builds a ParamType that runs json.loads and then model_validate in one step.
Clean errors and correct exit codes
A validation failure should never surface as a raw Python traceback. The Pydantic ValidationError carries a structured .errors() list — turn it into a tidy, field-addressed message and route it through Click's error machinery so the process exits with status 2 (the conventional "usage error" code):
from pydantic import ValidationError
import click
def format_errors(exc: ValidationError) -> str:
lines = []
for err in exc.errors():
loc = ".".join(str(p) for p in err["loc"]) or "(root)"
lines.append(f" {loc}: {err['msg']}")
return "validation failed:\n" + "\n".join(lines)
# inside a ParamType.convert or a callback:
try:
return DeployConfig.model_validate(data)
except ValidationError as exc:
raise click.BadParameter(format_errors(exc))
Now a bad cpu produces resources.cpu: Input should be less than or equal to 64 and an exit code that scripts and CI can detect — not a stack trace.
Validators that read well
The most useful validation code is the code that says what it means. Three shapes cover almost everything.
A constrained parameter type, for a rule about one value:
from typing import Annotated
import typer
@app.command()
def scale(
replicas: Annotated[int, typer.Option(min=1, max=50, help="Desired replica count.")] = 3,
timeout: Annotated[float, typer.Option(min=0.1, help="Seconds to wait per attempt.")] = 30.0,
) -> None:
...
Nothing in the body checks either value, because a value outside the range never reaches it — the framework prints a usage error naming the option and exits 2.
A converter, when the rule is about the shape of a string:
import re
from typing import Annotated
def parse_duration(raw: str) -> int:
"""Accept 30s, 5m, 2h and return seconds."""
match = re.fullmatch(r"(\d+)([smh])", raw)
if not match:
raise typer.BadParameter("expected a duration like 30s, 5m or 2h")
value, unit = int(match.group(1)), match.group(2)
return value * {"s": 1, "m": 60, "h": 3600}[unit]
@app.command()
def wait(
limit: Annotated[int, typer.Option(parser=parse_duration, help="How long to wait.")] = 300,
) -> None:
...
The command body receives an int in seconds. The parsing rule, the error message and the
documentation of the accepted format all live in one function that can be unit-tested without a
runner.
A model, when several values have to agree:
from pydantic import BaseModel, model_validator
class Window(BaseModel):
start: datetime
end: datetime
@model_validator(mode="after")
def check_order(self) -> "Window":
if self.start >= self.end:
raise ValueError("--start must be earlier than --end")
return self
That is the layer people most often skip, and then re-implement in three commands. A rule that spans two values cannot live on either parameter, so it needs somewhere of its own — either a model like this, or a single validation call at the top of the command.
Turning validation failures into good errors
A validation error is the message most users will see most often, so it is worth shaping deliberately. The framework gives you the right behaviour for free if you raise the right thing.
raise typer.BadParameter("must be a bucket name: lowercase letters, digits and hyphens")
That produces the usage line, names the offending option, prints your sentence, and exits 2 — the conventional code for "the command line was wrong". You never write that plumbing.
For failures that are not about the command line — a config file with a bad value, a payload that does not match the schema — the code should be different, because the fault is elsewhere:
try:
spec = JobSpec.model_validate_json(raw)
except ValidationError as exc:
first = exc.errors()[0]
path = ".".join(str(p) for p in first["loc"])
typer.secho(f"invalid spec at {path}: {first['msg']}", fg="red", err=True)
raise typer.Exit(65) # bad input data, not bad usage
Reporting the first error with its path is usually better than dumping all of them: a
validation library will happily produce fifteen messages for one missing key, and the reader only
needs the first thing to fix. Keep the full detail available behind --debug.
Three properties make a validation message actionable: it names the input (--replicas,
resources.cpu, line 12 of config.toml), it states what was expected, and it does not include
a traceback. A traceback for an expected condition reads as a crash, and it trains users to
ignore your output.
Where validation should not happen
Two habits undo the benefit of validating at the boundary.
Re-checking downstream. Once sync_directory is only ever called with a validated path, a
defensive if not source.exists() inside it is noise — and worse, it invites the assumption that
callers might pass something invalid, which is exactly the property you were trying to remove.
If a core function genuinely needs to be safe against arbitrary input, that is a design decision
to make explicitly, not by accident.
Validating in every command. When three commands take --config, the file should be found,
read and validated once in the group callback, and the result placed on the context. Repeating
the load in each command means three error messages that drift apart and three chances to forget
a case.
There is one class of check that genuinely belongs in the command body: anything about the state of the world at the moment of use. Whether a file still exists, whether a token is still valid, whether a remote is reachable — these cannot be settled at parse time and will change between the check and the use anyway. Handle them where the work happens, as ordinary failures with their own exit codes.
Validating paths, the most common case
Paths account for more CLI validation than everything else combined, and both frameworks have enough built in that you should almost never write the checks by hand.
@app.command()
def convert(
source: Annotated[Path, typer.Argument(exists=True, dir_okay=False, readable=True)],
out_dir: Annotated[Path, typer.Option(file_okay=False, writable=True)] = Path("."),
) -> None:
...
exists, file_okay, dir_okay, readable, writable and resolve_path cover the practical
space. The last one is worth turning on when the value is stored or logged: it expands the path to
an absolute one, so a relative path recorded in a config file cannot mean something different when
the tool is next run from a different directory.
Two cases still need thought. A path that must not exist — an output file you refuse to
overwrite — has no built-in flag, so it becomes a small validator with a message that names the
--force flag people will reach for. And a path that will be created later should be checked for
a writable parent, not for its own existence:
def writable_target(path: Path) -> Path:
if path.exists():
raise typer.BadParameter(f"{path} already exists (use --force to overwrite)")
if not path.parent.is_dir():
raise typer.BadParameter(f"{path.parent} does not exist")
return path
There is a race here in principle — the file could appear between the check and the write — and for a CLI that is almost always acceptable. What matters is that the common mistake is caught before the work starts rather than after twenty minutes of processing.
Validating input that did not come from the command line
The boundary is wider than argv. Three other sources deserve the same treatment.
Environment variables arrive as strings and are easy to forget about, because they are read
implicitly. If MYTOOL_RETRIES=lots should be an error, it has to be converted through the same
code path as the flag — which is the practical argument for merging all sources first and
coercing once, rather than converting each source separately.
Configuration files need shape validation as well as type validation. A model that rejects
unknown keys turns a typo like retires: 5 into a clear error instead of a setting that silently
never applies. That single behaviour catches more real misconfiguration than any amount of range
checking.
Standard input is the one people skip. If your tool reads piped data, decide what happens when
it receives an empty stream, invalid encoding, or something that is not the format you expected —
and produce an error that names the stream rather than a JSONDecodeError traceback.
raw = sys.stdin.read()
if not raw.strip():
typer.secho("no input on stdin (expected a JSON document)", fg="red", err=True)
raise typer.Exit(65)
Each of these is the same pattern in a different place: convert early, fail with a message that names the source, and use an exit code that says whose fault it was.
Testing the rules, not the plumbing
Validation is unusually cheap to test, because the interesting parts are functions that take a value and either return or raise.
import pytest
@pytest.mark.parametrize("raw,expected", [("30s", 30), ("5m", 300), ("2h", 7200)])
def test_parse_duration_accepts_known_units(raw, expected):
assert parse_duration(raw) == expected
@pytest.mark.parametrize("raw", ["", "30", "5x", "-1s", "1.5m"])
def test_parse_duration_rejects_everything_else(raw):
with pytest.raises(typer.BadParameter):
parse_duration(raw)
Two dozen cases, no runner, milliseconds. Add one invocation-level test per command to prove the converter is actually attached to the right option and that the failure exits 2 — that is the plumbing, and one test covers it:
def test_bad_duration_is_a_usage_error():
result = CliRunner().invoke(app, ["wait", "--limit", "soon"])
assert result.exit_code == 2
assert "30s" in result.output # the message suggests the accepted format
Asserting that the message names the accepted format is worth doing once. It is the assertion that fails when someone simplifies the error text and quietly removes the only hint the user had.
Frequently asked questions
Should I use Pydantic for CLI validation?
It earns its place when input has structure — nested JSON, a config file, several fields that constrain each other. For a handful of flat options, the framework's own parameter types are simpler and produce better command-line errors. Many projects end up with both: parameter types for the flags, a model for the config file and any JSON payloads.
How do I validate that two options are mutually exclusive?
Check it explicitly in one place, immediately after parsing. Neither Click nor Typer models exclusivity as a first-class concept (argparse does, via mutually exclusive groups, but the error message is worse). Three lines at the top of the command — or in the callback if the options are global — with a message that names both flags, is clearer than any decorator gymnastics.
What exit code should a validation failure use?
2 when the command line itself was wrong, because that is what users and frameworks already expect. For data that arrived from somewhere else, distinguish it: 65 for malformed input data and 78 for a bad configuration are the conventional choices, and the distinction is what lets a wrapper script tell "you typed it wrong" from "the config on this machine is broken".
Can I validate an option against a value from a config file?
Yes, but not at parse time — the config is not loaded when the parameter is converted. Do it in the validation step after parsing, where both the parsed options and the loaded settings are available. Trying to reach into the context from a parameter callback works in simple cases and becomes fragile as soon as ordering changes.
How do I keep validation logic testable?
Write it as plain functions that take values and raise, then attach them to parameters. A
converter like parse_duration above is a function you can drive with a table of inputs in a
dozen lines; the same rule embedded in a command body needs a runner and a full invocation for
every case.
Is it worth validating input that only I will ever type?
Yes, for one reason that has nothing to do with other users: the error message is a note to yourself. Six months later, a tool that says "expected a duration like 30s, 5m or 2h" saves you reading your own source to remember the format. Validation on a personal tool is documentation that cannot go out of date, and it costs the same three lines either way — with the added benefit that the rule is now testable.