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.

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.

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:

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.