Architecture

Command-Line Parsing with argparse

Build Python CLIs with the standard-library argparse: arguments, options, types, and subcommands, and know when to move up to Click or Typer.

Updated

argparse is the argument parser in the Python standard library, and for a surprising number of tools it is all you need. It ships with every interpreter, so a CLI built on it has zero third-party dependencies — nothing to pin, nothing to break on a pip resolution, nothing extra to audit. This overview shows you how to build a real parser with it, validate input properly, and recognize the point where a heavier framework earns its place.

TL;DR

  • Create a parser with argparse.ArgumentParser, add positional arguments with add_argument("name") and optional ones with add_argument("--flag").
  • Coerce and validate with type=, constrain with choices=, set fallbacks with default=, and collect multiples with nargs=. Boolean flags use action="store_true".
  • parse_args() returns a plain Namespace; read values as attributes.
  • Reach for subparsers for git-style subcommands.
  • Graduate to Click or Typer once you want nested groups, shell completion, and less boilerplate — and when you do, follow the argparse-to-Typer migration guide.
Anatomy of an argparse parser Anatomy of an argparse parser ArgumentParser positional arguments path, count optional arguments --verbose, --out subparsers group add · remove · list parse_args() Namespace args.path args.verbose args.func one parser validates arguments and subcommands, producing a typed Namespace

Why start with argparse

Every third-party CLI framework has to justify a dependency. argparse never does — it is part of Python, documented alongside the language, and stable across releases. If you are writing an internal script, a build helper, or a tool that must run in a locked-down environment where installing packages is painful, the calculus is simple: reach for the stdlib first.

It also teaches you the model that Click and Typer sit on top of. Positional vs optional arguments, type coercion, nargs, and subcommand dispatch are the same ideas everywhere; argparse just makes you spell them out. Learn it here and the frameworks feel like shortcuts rather than magic.

A runnable parser

Here is a complete program — a small file-copier — that uses a positional argument, a typed option, and a boolean flag. Save it as mvcp.py and run it directly.

# mvcp.py
import argparse
from pathlib import Path

def main() -> None:
    parser = argparse.ArgumentParser(
        prog="mvcp",
        description="Copy a file, optionally renaming it.",
    )
    parser.add_argument("source", type=Path, help="File to copy.")
    parser.add_argument(
        "--dest-dir",
        type=Path,
        default=Path.cwd(),
        help="Directory to copy into (default: current directory).",
    )
    parser.add_argument(
        "--overwrite",
        action="store_true",
        help="Replace the destination if it already exists.",
    )

    args = parser.parse_args()
    target = args.dest_dir / args.source.name
    if target.exists() and not args.overwrite:
        parser.error(f"{target} exists; pass --overwrite to replace it")
    print(f"Would copy {args.source} -> {target}")

if __name__ == "__main__":
    main()
$ python mvcp.py notes.txt --dest-dir backup/
Would copy notes.txt -> backup/notes.txt

$ python mvcp.py notes.txt --dest-dir backup/
mvcp.py: error: backup/notes.txt exists; pass --overwrite to replace it

Three things are happening. source has no leading dash, so it is positional and required. --dest-dir starts with dashes, so it is optional and takes a value. --overwrite is a flagaction="store_true" means it defaults to False and flips to True when present, taking no value.

Notice type=Path. argparse calls that callable on the raw string, so args.source is a pathlib.Path, not a str. Any one-argument callable works here, which is the hook you will use for validation below.

choices, default, nargs, and flags

These four knobs cover the vast majority of real arguments.

What each nargs value accepts A comparison of argparse nargs values: a single value, an optional value, zero or more, one or more, and a fixed count, with the resulting Python type. What each nargs value accepts nargs= Accepts You get Missing gives (default) exactly one a str a usage error '?' zero or one value or default the default '*' any number a list an empty list '+' one or more a list a usage error 2 (an int) exactly two a 2-item list a usage error Reach for '+' when at least one value is genuinely required — it produces the error message for you.
parser.add_argument(
    "--log-level",
    choices=["debug", "info", "warning", "error"],
    default="info",
    help="Verbosity (default: info).",
)
parser.add_argument(
    "paths",
    nargs="+",              # one or more, collected into a list
    type=Path,
    help="One or more files to process.",
)
parser.add_argument(
    "--tag",
    action="append",        # repeatable: --tag a --tag b -> ["a", "b"]
    default=[],
    help="Attach a tag; repeat for several.",
)
parser.add_argument("--dry-run", action="store_true")
  • choices restricts a value to a fixed set. argparse rejects anything else before your code runs and lists the valid options in the error, so you never validate the enum by hand.
  • default supplies a value when the flag is absent. Optionals without a default get None.
  • nargs controls how many values an argument consumes: "+" (one or more), "*" (zero or more), "?" (optional single), or an integer for an exact count. nargs="+" on a positional is how you accept a list of files.
  • action="store_true" for boolean switches, action="append" to collect a repeatable option into a list.

Validation with type= callables and parser.error()

type= is not just for int and Path — any callable that takes a string and either returns a value or raises is fair game. Raise argparse.ArgumentTypeError (or ValueError) and argparse turns it into a clean, non-zero-exit error message instead of a traceback.

Where a type= callable sits The path a raw argument takes: the shell string is passed to the type callable, which either returns a converted value or raises, at which point argparse prints usage and exits with status two. Where a type= callable sits Raw string straight from argv type= callable convert and check Namespace a typed attribute Your function no re-checking argparse calls it on success dispatch A raise inside the callable becomes a usage error and exit code 2 — you never write that branch yourself.
import argparse

def positive_int(raw: str) -> int:
    value = int(raw)              # ValueError here is caught by argparse too
    if value <= 0:
        raise argparse.ArgumentTypeError(f"{raw!r} is not a positive integer")
    return value

parser = argparse.ArgumentParser()
parser.add_argument("--workers", type=positive_int, default=4)
$ python app.py --workers 0
app.py: error: argument --workers: '0' is not a positive integer

For validation that spans several arguments (say, "--end must be after --start"), do it after parsing and report failures through parser.error(), which prints to stderr and exits with status 2 — the conventional argparse usage-error code:

args = parser.parse_args()
if args.end <= args.start:
    parser.error("--end must be later than --start")

Using parser.error() rather than print(); sys.exit() keeps your error output consistent with the parser's own messages. For the broader picture of exit statuses, see choosing exit codes for CLI tools.

Subcommands: a first look

Once a tool does more than one job — tool build, tool deploy, tool clean — you want subcommands, each with its own arguments and help. argparse provides these through add_subparsers():

parser = argparse.ArgumentParser(prog="tool")
sub = parser.add_subparsers(dest="command", required=True)

build = sub.add_parser("build", help="Build the project.")
build.add_argument("--release", action="store_true")

deploy = sub.add_parser("deploy", help="Deploy the project.")
deploy.add_argument("target")

args = parser.parse_args()

That is enough to give tool build --release and tool deploy prod their own parsers. The clean way to route each subcommand to a handler function — with set_defaults(func=...), shared parent parsers, and nesting — is a topic of its own: argparse subparsers for subcommands.

Help output for free

You never write --help. argparse builds usage text from your prog, description, and every argument's help string, and wires up -h/--help automatically:

$ python mvcp.py --help
usage: mvcp [-h] [--dest-dir DEST_DIR] [--overwrite] source

Copy a file, optionally renaming it.

positional arguments:
  source               File to copy.

options:
  -h, --help           show this help message and exit
  --dest-dir DEST_DIR  Directory to copy into (default: current directory).
  --overwrite          Replace the destination if it already exists.

Add an epilog= for examples, and set formatter_class=argparse.RawDescriptionHelpFormatter if you want to control the wrapping of your description yourself.

When to graduate to Click or Typer

argparse starts to fight you at a predictable point. The trade-offs:

Stay on argparse, or move? A decision diagram: if the tool must ship with zero dependencies stay on argparse, otherwise move to Click or Typer for completion and nested commands. Stay on argparse, or move? Can the tool take a third-party dependency? No — it ships into locked-down or stdlib-only environments argparse always available, more wiring Yes — a wheel with dependencies is fine Click / Typer completion and groups for free The dependency question decides it; everything else is a preference you can change later.
ConcernargparseClick / Typer
DependenciesNone (stdlib)One framework
BoilerplateHigh — every arg spelled outLow, especially with Typer's type hints
Nested subcommandsManual and verboseFirst-class groups
Shared context between commandsRoll your ownctx.obj / dependency injection
Shell completionNot built inBuilt in
Rich help, colors, promptsManualIncluded

If your tool is a handful of commands with simple options, argparse is the right tool and adding a dependency is over-engineering. Once you find yourself hand-rolling subcommand dispatch, wanting tab completion, or copy-pasting the same global options onto every command, a framework pays for itself. Start with Typer vs Click: when to use each to pick one, then either build subcommands in Click or follow the migration path to Typer.

Production notes

  • Namespace is intentionally dumb. parse_args() returns an argparse.Namespace with no validation of its own. For a typed object, feed it into a dataclass: Config(**vars(args)). That gives you editor autocompletion and mypy coverage downstream.
  • Test without a subprocess. Call parser.parse_args(["notes.txt", "--overwrite"]) with an explicit list in unit tests — it reads sys.argv only when you pass None. Assert on the returned namespace directly.
  • Hyphens become underscores. --dest-dir is available as args.dest_dir. argparse translates automatically; do not look for args["dest-dir"].
  • Exit code 2 for usage errors. parser.error() and unknown-argument failures exit with status 2, a convention worth preserving if you later migrate. See structuring multi-command Python CLIs for keeping parsing separate from logic so a future framework swap stays cheap.
  • parse_known_args() returns a (namespace, leftovers) tuple when you need to forward unrecognized flags to a wrapped tool, rather than erroring on them.

Argument groups, prefixes and the details that show

Beyond the basics, four argparse features do most of the work of making a stdlib CLI feel finished.

Argument groups organise --help into sections, which is the difference between a readable help screen and a list of thirty flags:

output = parser.add_argument_group("output options")
output.add_argument("--json", action="store_true", help="Emit machine-readable output.")
output.add_argument("--quiet", "-q", action="store_true", help="Only report failures.")

Mutually exclusive groups express a rule the parser can enforce, so you do not check it in the body:

mode = parser.add_mutually_exclusive_group()
mode.add_argument("--fast", action="store_true", help="Skip verification.")
mode.add_argument("--thorough", action="store_true", help="Verify every file.")

argparse.SUPPRESS is the key to layered configuration. An option with default=argparse.SUPPRESS simply does not appear in the namespace when the user did not pass it, which is what lets a config file supply the value instead:

parser.add_argument("--retries", type=int, default=argparse.SUPPRESS)
overrides = vars(parser.parse_args())        # only the keys the user actually set
settings = {**defaults, **from_file, **from_env, **overrides}

Without it, a parser default of 3 is indistinguishable from the user typing 3, and the config file can never win.

prefix_chars and allow_abbrev are worth one decision each. Leave prefix_chars alone. Turn allow_abbrev=False on: by default argparse accepts any unambiguous prefix, so --ret works today and breaks the moment you add --retry-delay. Every script using the short form breaks at once, for a convenience nobody asked for.

Better error messages and help output

argparse gives you three levers, and using them costs a few lines.

parser = argparse.ArgumentParser(
    prog="mytool",
    description="Sync a directory to a bucket.",
    epilog=(
        "examples:\n"
        "  mytool sync ./data --retries 5\n"
        "  mytool sync ./data --dry-run | tee plan.txt\n"
    ),
    formatter_class=argparse.RawDescriptionHelpFormatter,
    allow_abbrev=False,
)

RawDescriptionHelpFormatter preserves the newlines in your epilog, which is the only way to get readable examples. ArgumentDefaultsHelpFormatter appends the default to each help string automatically — useful, though it fights with SUPPRESS, so pick one approach per parser.

For custom validation, parser.error() is the function that produces the conventional behaviour: usage line, message, exit code 2.

def positive_int(raw: str) -> int:
    value = int(raw)                     # ValueError here becomes a usage error automatically
    if value < 1:
        raise argparse.ArgumentTypeError("must be 1 or greater")
    return value

Raising ArgumentTypeError from a type= callable is the idiomatic form — argparse catches it, prefixes the option name, and exits 2. Raising a bare ValueError works too but produces a slightly less specific message.

What you end up writing yourself

Knowing the gaps is what makes the argparse-or-framework decision concrete. Four things are missing, and each is a known quantity rather than a mystery.

Shell completion. There is no built-in support. argcomplete covers it with one decorator and a registration step, but it is a third-party dependency — which, if the reason you chose argparse was to avoid dependencies, is worth noticing.

Dispatch. set_defaults(func=handler) is the standard pattern and it works well, but you write it, and you write the "no subcommand given" branch yourself:

args = parser.parse_args()
if not hasattr(args, "func"):
    parser.print_help()
    raise SystemExit(2)
raise SystemExit(args.func(args))

Shared setup. There is no equivalent of a group callback that runs before every subcommand, so configuration loading and logging setup go in main() before dispatch — which is fine, and is one more thing to remember when adding a command.

Context. Nothing threads shared state through the tree, so it travels in the Namespace or in a variable you pass to the handler. Explicit, and slightly more code at every level.

None of these is hard. Together they are perhaps two hundred lines you own and maintain, which is a reasonable trade for zero dependencies and a poor one if you never needed that constraint.

Structuring an argparse CLI that grows

The stdlib gives you no opinion about layout, which means the discipline has to come from you. Three conventions keep an argparse tool from turning into one long module.

Build the parser in a function. A module-level parser = ArgumentParser(...) runs at import time and cannot be tested without side effects. A build_parser() that returns the parser is importable, testable and reusable:

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog="mytool", allow_abbrev=False)
    sub = parser.add_subparsers(dest="command", required=True)
    register_sync(sub)
    register_status(sub)
    return parser

One registration function per command, living beside its handler:

# mytool/commands/sync.py
def register_sync(sub: argparse._SubParsersAction) -> None:
    parser = sub.add_parser("sync", help="Sync a directory to the bucket.")
    parser.add_argument("source", type=Path)
    parser.add_argument("--retries", type=positive_int, default=argparse.SUPPRESS)
    parser.set_defaults(func=run_sync)

def run_sync(args: argparse.Namespace) -> int:
    result = core.sync_directory(args.source, retries=getattr(args, "retries", 3))
    print(f"{result.uploaded} uploaded")
    return 0

Adding a command becomes one file and one line in build_parser, and each handler returns an exit code rather than calling sys.exit, so the dispatcher owns termination.

Keep main() tiny. Parse, dispatch, translate exceptions:

def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        return args.func(args)
    except MytoolError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1

Accepting argv as a parameter is what makes the whole program testable in-process: main(["sync", "./data"]) returns an exit code with no subprocess involved.

Frequently asked questions

Is argparse still a reasonable choice in 2026?

Yes, for the specific case of a tool that cannot take dependencies: an installer, a bootstrap script, something shipped into a locked image, or a utility that must run on a bare interpreter. It is stable, well documented and in every Python. For anything with a growing command tree and no dependency constraint, the machinery Click and Typer provide is exactly the code you would otherwise be writing.

How do I test an argparse CLI?

Build the parser in a function so tests can call it, then assert on the parsed namespace rather than by running a subprocess:

def test_defaults():
    args = build_parser().parse_args(["sync", "./data"])
    assert args.retries == 3

For error paths, pytest.raises(SystemExit) captures the exit and capsys captures the usage message. That keeps the tests fast and gives you real tracebacks when something is wrong.

Why does my subcommand's dest collide?

Because each add_subparsers() call needs its own dest. With two levels, the inner one overwrites the outer if both default to the same name — pass dest="command" at the top level and dest="remote_command" on the nested one, and both end up in the namespace.

Can argparse read defaults from a config file?

Not directly, but parser.set_defaults(**config) applies a mapping before parsing, so values from a file become the defaults and any flag the user passes still wins. Combine that with default=argparse.SUPPRESS on the options themselves and you have a working precedence chain in about five lines.

How do I show a version flag?

parser.add_argument("--version", action="version", version=f"mytool {version('mytool')}"). The version action prints and exits before other arguments are validated, which is the behaviour you want — the flag works even when the rest of the command line is incomplete.

Does argparse handle Unicode and Windows paths correctly?

Yes — arguments arrive as str already decoded by Python, and type=Path produces a Path that behaves correctly on every platform. The cross-platform problems people attribute to argparse are almost always elsewhere: a hard-coded forward slash in a default, or a shell that expanded a glob differently. Use pathlib throughout, let the shell do its own quoting, and run the test suite on Windows in CI rather than special-casing anything in the parser.