Paths account for more command-line validation than everything else combined, and both frameworks have enough built in that you should rarely write the checks by hand. What is left over is the interesting part: paths that do not exist yet, paths that must stay inside a root, and errors that tell the user which path the tool actually looked at.
TL;DR
- Use the built-in checks:
exists,file_okay,dir_okay,readable,writable,resolve_path. - For a path you will create, check the parent, not the path.
resolve()thenis_relative_to(root)is the whole containment check — string prefixes are not.- Always print the resolved absolute path in errors; it ends the "but it is right there" conversation.
- Use a distinct exit code for a missing input file (66 is conventional).
Prerequisites
A Typer or Click application, and pathlib. Everything here works the same in argparse with a
type= callable.
What the framework already checks
@app.command()
def convert(
source: Annotated[Path, typer.Argument(
exists=True, dir_okay=False, readable=True,
help="File to convert.",
)],
out_dir: Annotated[Path, typer.Option(
file_okay=False, writable=True, resolve_path=True,
help="Directory to write results into.",
)] = Path("."),
) -> None:
...
Five keywords, and the command body contains no path checks at all. A missing file, a directory where a file was expected, or a permission problem all produce a usage error naming the option and exit code 2 before your code runs.
resolve_path=True is the one people miss. It expands the value to an absolute path, which matters
whenever you store it, log it, or compare it — a relative path recorded in a config file means
something different the next time the tool runs from a different directory.
The Click spelling is the same set of arguments on click.Path:
@click.option("--out-dir", type=click.Path(file_okay=False, writable=True, path_type=Path))
Note path_type=Path: without it Click hands you a str, and you end up converting at every call
site.
Paths that do not exist yet
The gap in the built-ins is an output path, where the file should not exist and the directory should. That needs a small validator:
def writable_target(path: Path) -> Path:
resolved = path.expanduser().resolve()
if resolved.exists():
raise typer.BadParameter(f"{resolved} already exists (use --force to overwrite)")
if not resolved.parent.is_dir():
raise typer.BadParameter(f"{resolved.parent} does not exist")
return resolved
@app.command()
def export(
output: Annotated[Path, typer.Option(parser=writable_target, help="Where to write the export.")],
) -> None:
...
Two details make it useful. expanduser() handles ~/exports/data.json, which the shell expands
only when unquoted — a quoted path arrives with the tilde intact and fails confusingly without it.
And the message names --force, so the user learns the fix rather than the rule.
There is a theoretical race here — the file could appear between the check and the write — and for a CLI that is acceptable. What matters is catching the common mistake before twenty minutes of processing, not guaranteeing atomicity.
Refusing a path that escapes its root
When a path comes from a config file, an archive entry, or a remote payload, it may be trying to leave the directory you intended:
def inside(root: Path, candidate: Path) -> Path:
resolved = (root / candidate).resolve()
if not resolved.is_relative_to(root.resolve()):
raise InputDataError(f"{candidate} escapes the output directory")
return resolved
resolve() before comparing is the entire check: it normalises .. segments and follows symlinks,
which is what a string prefix test misses. Path.is_relative_to has been available since Python
3.9 and is clearer than comparing parts.
This matters most when extracting archives or writing files whose names came from data — the classic path traversal shape. If your tool never handles untrusted paths, you do not need it; if it does, this is the check.
Errors people can act on
The single most useful habit is printing the resolved path rather than what the user typed:
raise InputDataError(f"cannot read {path.resolve()}: no such file")
A user who typed ./data/jobs.json from the wrong directory sees an absolute path that is obviously
not where they thought, and the conversation ends there. Printing back what they typed tells them
nothing they did not already believe.
Two more:
Say which check failed. "not a file" and "not readable" and "does not exist" are three different fixes. A generic "invalid path" makes the user try all three.
Use a distinct exit code. 66 (EX_NOINPUT) for a missing input file lets a wrapper script tell
"the file is not there yet" from "the tool failed", which is a genuinely useful distinction in a
pipeline.
UX considerations
Fail before the work, not after. Validating an output directory at the top of a command that
runs for twenty minutes is the difference between an immediate error and a wasted afternoon. This is
the argument for writable=True on the option rather than a check at write time.
Accept - where it makes sense. A path argument that could be stdin or stdout should honour the
dash convention, as described in
reading piped input.
Do not resolve paths you show back in progress output. An absolute path in every progress line is noise; show the relative form while working and the resolved form in errors.
Be careful with ~ in configuration. A path read from a file is not shell-expanded, so
expanduser() is your job. It is a one-line fix and a recurring bug report without it.
Testing the behaviour
Path validation is cheap to test with tmp_path, and the cases worth covering are the failures:
def test_missing_input_exits_66(cli, tmp_path):
result = cli.invoke(app, ["convert", str(tmp_path / "nope.txt")])
assert result.exit_code in (2, 66)
assert str(tmp_path) in result.stderr # the resolved path is shown
def test_directory_where_a_file_is_expected(cli, tmp_path):
assert cli.invoke(app, ["convert", str(tmp_path)]).exit_code == 2
def test_existing_output_needs_force(cli, tmp_path):
target = tmp_path / "out.json"
target.write_text("{}")
result = cli.invoke(app, ["export", "--output", str(target)])
assert result.exit_code == 2
assert "--force" in result.stderr # the message names the fix
@pytest.mark.parametrize("candidate", ["../escape.txt", "sub/../../escape.txt"])
def test_paths_escaping_the_root_are_refused(tmp_path, candidate):
with pytest.raises(InputDataError):
inside(tmp_path, Path(candidate))
The parametrised containment test is worth keeping even if you think the input is trusted — trust changes, and this is one of the few validation rules with a security consequence.
Conclusion
Let the framework check existence, kind and permissions; write a small validator for paths you will create; resolve before comparing when containment matters; and print the resolved path in every error. Between them these remove nearly all hand-written path handling from command bodies, and the handful of lines that remain are the ones worth reading.
Frequently asked questions
Should I use Path or str for path parameters?
Path, always — both frameworks will produce one for you, and it removes an entire category of
string-manipulation bugs around separators and joining. In Click, remember path_type=Path;
Typer infers it from the annotation.
What about paths with spaces or unusual characters?
They arrive correctly: the shell handles quoting, and Python gives you the decoded string. Problems here are nearly always your own code building a command line for a subprocess — pass a list of arguments rather than a formatted string and the issue disappears.
How do I validate that a directory is writable without creating a file?
os.access(path, os.W_OK) is what the frameworks use, and it is advisory — the definitive test is
attempting the write. For a long-running command, an early advisory check plus a clear error at
write time is the right combination.
Should the tool create a missing output directory?
Only if the user asked, with a flag or a documented default. Silently creating directories surprises
people, especially when a typo produces a new directory tree instead of an error. --create-dirs is
the common convention.
Does resolve() fail on a path that does not exist?
Not since Python 3.6 — it resolves as far as it can with strict=False, which is the default. Pass
strict=True when you specifically want it to raise for a missing path, though the framework's
exists=True is usually the better place for that check.
How do I handle a path argument that may be a glob?
Let the shell expand it and accept a list. mytool convert *.csv arrives as many arguments on Unix,
so a list[Path] parameter is all you need. Expanding globs yourself is only necessary on Windows,
where the shell does not — and if you do, do it explicitly with Path.glob rather than shelling
out, and document that the pattern is interpreted by the tool.
Should symlinks be followed?
resolve() follows them, which is what you want for containment checks and usually what you want
for reading. Where it matters is deletion and overwriting: a symlink pointing outside the intended
directory turns an innocuous write into a surprise. If your tool deletes or replaces files, decide
deliberately and say so in the help text.