mytool copy a.txt b.txt out/ or mytool copy a.txt b.txt --to out/? mytool deploy api production 3 or mytool deploy api --env production --replicas 3? Every command signature makes this choice for every input, and it is one of the few design decisions that is genuinely hard to undo: once scripts call your command with three positional arguments in a particular order, that order is part of your public interface forever. Positional arguments are short and natural for the one or two things a command acts on; options are self-describing, order-independent and safe to extend. This guide turns that into concrete rules, shows the patterns that work well with Typer and Click, and adds a snapshot test that catches accidental breaking changes to a command's signature. It belongs to the CLI interface design topic.
Prerequisites
- Typer or Click, and the naming conventions from naming commands and flags consistently.
- Familiarity with POSIX argument syntax, covered in following POSIX and GNU argument conventions.
The trade-off
A positional argument is identified by its position, so it is brief to type and reads like a sentence — cp a b, git checkout main — but it carries no name. A reader of mytool deploy api production 3 has to know the signature to understand the 3, and the command can only ever add new positionals at the end, behind optional ones, which quickly becomes impossible to explain. An option is identified by its name, so --replicas 3 documents itself, can appear in any order, can be added later without breaking anyone, and can have a default.
The rules
- Positional arguments are the objects the command acts on. The files to copy, the service to deploy, the ticket to show. Ask "what does this command operate on?" — the answer is the positional. Everything that answers "how?" is an option.
- At most one kind of positional, ideally one or two values.
mytool show TICKETandmytool copy SOURCE...are clear.mytool deploy SERVICE ENV REGIONis a riddle. - Variadic positionals go last, and there is only one.
copy SOURCE... --to DESTworks;copy SOURCE... DEST(ascpdoes) is a classic source of mistakes because the last item silently changes meaning. - Required does not mean positional. A required destination can be a required option,
--to DEST. Required options are slightly unusual, but much clearer than a second positional. - Anything with a sensible default is an option. A positional that can be omitted makes the meaning of the others depend on how many were given.
- Booleans are always options.
--overwrite, never a positionaltrue. - New inputs are options. Adding a positional to an existing command breaks every script that passed arguments after it; adding an option breaks nothing.
The recipe
Applying the rules to a copy command in Typer:
# src/mytool/cli.py
from pathlib import Path
from typing import Annotated
import typer
app = typer.Typer()
@app.callback()
def main() -> None:
"""File tool."""
@app.command()
def copy(
sources: Annotated[list[Path], typer.Argument(help="Files to copy.", show_default=False)],
dest: Annotated[Path, typer.Option("--to", "-t", help="Destination directory.")],
overwrite: Annotated[bool, typer.Option("--overwrite", help="Replace existing files.")] = False,
) -> None:
"""Copy SOURCES into the --to directory."""
for src in sources:
typer.echo(f"{src} -> {dest / src.name}{' (overwrite)' if overwrite else ''}")
if __name__ == "__main__":
app()
mytool copy a.txt b.txt --to out/
mytool copy --to out/ *.txt # options may come first
mytool copy --to out/ -- -weird-name.txt # -- ends options, so a dash-prefixed file works
The variadic sources positional collects every non-option argument, and the destination is unambiguous because it is named. In Click, the same design is @click.argument("sources", nargs=-1, required=True, type=click.Path(path_type=Path)) plus @click.option("--to", "-t", "dest", required=True, ...).
When a second positional is right
Some commands have two natural objects with an obvious order — mytool diff OLD NEW, mytool rename FROM TO, git cherry-pick A..B. Two positionals are fine when the order mirrors language ("rename from to"), both are required, and the help names them clearly. If you find yourself explaining the order in the help text, use options.
Stdin as an implicit positional
Many commands accept their main input either as a positional file or from standard input. The convention is a positional that defaults to -, meaning stdin, as in reading piped input in Python CLIs. That keeps the common file case short while letting the command sit in a pipeline.
UX considerations
- Help should read like usage.
Usage: mytool copy [OPTIONS] SOURCES...tells the user immediately what the command acts on. Name positionals as nouns in the help (SOURCES,TICKET), not asARG1. - Allow options anywhere. Click and Typer accept options before or after positionals by default; keep it that way, since users type them in both orders.
- Support
--. File names beginning with-are rare but real; the end-of-options marker must work. - Prefer explicit over clever. A positional that is "a file, or a URL, or a ticket number" depending on its shape is convenient until the day the shapes overlap. Separate options (
--file,--url) or subcommands are clearer. - Plan for completion. Positional files complete naturally in shells; named options with choices complete better than positional ones. See shell completion for Python CLIs.
Testing the behaviour
Behavioural tests check that both orders and the -- marker work. A second kind of test protects the signature itself: describe every command's parameters as data, commit that description, and fail when it changes unexpectedly. Because Typer builds Click-style command objects, the description can be computed generically:
# tests/test_interface.py
import json
from pathlib import Path
from typer.main import get_command
from typer.testing import CliRunner
from mytool.cli import app
SNAPSHOT = Path(__file__).parent / "interface.json"
def describe(cmd, path=()):
if hasattr(cmd, "commands"): # a group: recurse
out = {}
for name, sub in sorted(cmd.commands.items()):
out.update(describe(sub, path + (name,)))
return out
return {" ".join(path): [
{"kind": p.param_type_name, "name": p.name, "required": p.required,
"opts": sorted(p.opts)}
for p in cmd.params if p.name != "help"
]}
def test_interface_matches_snapshot():
current = describe(get_command(app))
if not SNAPSHOT.exists():
SNAPSHOT.write_text(json.dumps(current, indent=2) + "\n")
assert current == json.loads(SNAPSHOT.read_text()), (
"the command-line interface changed; if intentional, delete tests/interface.json "
"and re-run to regenerate it, and record the change in the changelog"
)
def test_options_before_positionals_and_double_dash():
result = CliRunner().invoke(app, ["copy", "--to", "out", "a.txt", "--", "-weird.txt"])
assert result.exit_code == 0
assert "-weird.txt -> out/-weird.txt" in result.output
The snapshot test turns "someone added a positional to deploy" from a silent breaking change into a failing test with instructions, and the regenerated JSON shows reviewers exactly what changed. Duck-typing on commands matters with Typer, whose command objects come from its vendored copy of Click rather than the click package, the same duck-typed walk used by the flag-naming test in naming commands and flags consistently: an isinstance(cmd, click.Group) check would be false for every Typer group.
Conclusion
Use positional arguments for the one or two things a command acts on, and options for everything else — especially anything with a default, anything boolean and anything added later. Keep at most one variadic positional and put it last, prefer a required option to a second positional, support --, and protect the result with an interface snapshot test so the signature only changes on purpose. That is how a command stays both quick to type and safe to extend.
Frequently asked questions
Why do cp and mv take the destination as the last positional?
History: they predate long options. The design is convenient for experts and a common source of mistakes (cp *.txt with a glob that matches one file overwrites it). New tools do not need to copy it; --to or --target-directory (which GNU cp added for exactly this reason) is clearer.
Can an option be required?
Yes, and it is often the right choice for a required input that is not the command's main object. Some style guides discourage required options; in practice they are much easier to read and evolve than a second or third positional.
How do I rename a positional without breaking users?
Positional names only appear in help, so renaming is safe. Changing their number or order is what breaks scripts. If the order must change, add a new command or option and deprecate the old form, as in versioning and deprecating CLI flags.
Should subcommands replace positionals?
When a positional selects between different behaviours — mytool user add versus mytool user remove — it is really a subcommand, and modelling it as one gives each behaviour its own options and help. Positionals should be data, not modes.
How many options are too many?
There is no hard limit, but a command with more than a dozen options usually hides two commands, or a configuration file trying to get out. Group related options under help panels, move rarely used ones into a config file read with the precedence rules in config precedence: flags, env, files, defaults, and split commands whose options mostly do not combine.