Architecture

Positional Arguments vs Options: Designing a CLI Signature

Decide what should be a positional argument and what an option in a Python CLI: the rules that keep commands readable and extensible, variadic arguments, and an interface snapshot test.

Updated

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

The trade-off

Positional argument or option? A comparison of positional arguments and options on brevity, readability, ordering, defaults and adding new inputs later. Positional argument or option? Property Positional Option Brevity short, sentence-like longer Self-describing no — position only yes — --replicas 3 Order matters yes no Add one later breaks scripts safe Positionals for what the command acts on; options for how.

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

  1. 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.
  2. At most one kind of positional, ideally one or two values. mytool show TICKET and mytool copy SOURCE... are clear. mytool deploy SERVICE ENV REGION is a riddle.
  3. Variadic positionals go last, and there is only one. copy SOURCE... --to DEST works; copy SOURCE... DEST (as cp does) is a classic source of mistakes because the last item silently changes meaning.
  4. 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.
  5. 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.
  6. Booleans are always options. --overwrite, never a positional true.
  7. 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, ...).

One signature, three call styles Terminal session calling a copy command with sources first, options first, and with a double dash before a file name starting with a dash. One signature, three call styles bash $ mytool copy a.txt b.txt --to out/ a.txt -> out/a.txt $ mytool copy --to out/ *.txt a.txt -> out/a.txt $ mytool copy --to out/ -- -weird-name.txt -weird-name.txt -> out/-weird-name.txt A named destination makes every ordering unambiguous.

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 as ARG1.
  • 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.
Signatures, judged Example command signatures with an assessment of each and a better alternative where needed. Signatures, judged Signature Verdict Better show TICKET clear — copy SOURCE... DEST last item changes meaning copy SOURCE... --to DEST deploy SERVICE ENV REPLICAS a riddle deploy SERVICE --env --replicas rename FROM TO fine — order is language — If the help has to explain the order, use options.

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.