Build Real Command-Line Tools with argparse: Subcommands, Validation, and Help That Doesn't Suck
Learn how to turn a throwaway script into a polished CLI with Python's built-in argparse — flags, types, custom validation, mutually exclusive groups, shared parent parsers, and git-style subcommands that dispatch cleanly.
Every Python developer writes the same script twice. The first version has hardcoded paths at the top and a comment that says # change this before running. The second version — written three weeks later, after a colleague asks to use it — needs a real interface. That's where argparse comes in.
argparse ships with the standard library, has no dependencies, and does far more than most people use it for. This guide walks from the basics to the patterns that make a CLI feel like a real tool: typed and validated arguments, mutually exclusive options, shared flags across subcommands, and a dispatch pattern that keeps your code flat.
The 30-second version
A minimal parser needs three things: a parser object, some arguments, and a call to parse_args().
import argparse
parser = argparse.ArgumentParser(
prog="resize",
description="Resize images in a directory.",
)
parser.add_argument("directory", help="folder containing images")
parser.add_argument("--width", type=int, default=800, help="target width in pixels")
parser.add_argument("--verbose", "-v", action="store_true", help="print each file")
args = parser.parse_args()
print(args.directory, args.width, args.verbose)
Run it with --help and argparse generates usage text, lists every option, and exits. Pass a non-integer to --width and it prints an error and exits with status 2 — the conventional exit code for CLI misuse. You got all of that from nine lines.
Note the naming rule: positional arguments become attributes under their own name (args.directory), while optional arguments strip the leading dashes and convert internal hyphens to underscores. So --dry-run becomes args.dry_run.
Actions: what argparse does when it sees a flag
The action parameter controls how a value is stored. The defaults cover most needs, but knowing the full set saves you from writing post-processing code.
parser = argparse.ArgumentParser()
# store (default) — takes one value
parser.add_argument("--output")
# store_true / store_false — boolean switch, no value consumed
parser.add_argument("--dry-run", action="store_true")
# append — repeatable flag, collects into a list
parser.add_argument("--exclude", action="append", default=[])
# count — how many times the flag appeared
parser.add_argument("-v", "--verbose", action="count", default=0)
# version — prints and exits
parser.add_argument("--version", action="version", version="%(prog)s 2.1.0")
args = parser.parse_args(["--exclude", "*.log", "--exclude", "*.tmp", "-vv"])
print(args.exclude) # ['*.log', '*.tmp']
print(args.verbose) # 2
The count action is how tools like ssh and curl implement -v, -vv, -vvv. Map it straight onto logging levels:
import logging
LEVELS = [logging.WARNING, logging.INFO, logging.DEBUG]
level = LEVELS[min(args.verbose, len(LEVELS) - 1)]
logging.basicConfig(level=level)
One gotcha worth internalising: always set default=[] on an append argument and default=0 on a count argument. Without an explicit default, argparse gives you None when the flag is never passed, and your for loop blows up on the empty case.
For boolean flags where you want both an on and an off switch, argparse.BooleanOptionalAction (Python 3.9+) generates the pair for you:
parser.add_argument(
"--color",
action=argparse.BooleanOptionalAction,
default=True,
help="colorize output",
)
# accepts both --color and --no-color
Types and validation
The type parameter takes any callable that accepts a string and returns a value. That means int, float, and pathlib.Path work out of the box — and so does anything you write yourself.
from pathlib import Path
parser.add_argument("--config", type=Path)
parser.add_argument("--timeout", type=float, default=5.0)
For custom validation, raise argparse.ArgumentTypeError. argparse catches it and turns it into a clean error message rather than a traceback:
import argparse
def positive_int(value: str) -> int:
try:
number = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"{value!r} is not an integer")
if number <= 0:
raise argparse.ArgumentTypeError(f"expected a positive integer, got {number}")
return number
parser.add_argument("--workers", type=positive_int, default=4)
Passing --workers -3 now produces error: argument --workers: expected a positive integer, got -3 instead of a stack trace. Resist the urge to call sys.exit() inside a type function — raising the proper exception lets argparse attach the argument name and usage line for you.
When the valid values are a fixed set, use choices:
parser.add_argument("--format", choices=["json", "csv", "yaml"], default="json")
argparse validates the input and lists the options in the help text automatically.
nargs: arguments that take more than one value
parser = argparse.ArgumentParser()
parser.add_argument("files", nargs="+") # one or more (error if zero)
parser.add_argument("--size", nargs=2, type=int) # exactly two: --size 1920 1080
parser.add_argument("--tags", nargs="*", default=[]) # zero or more
args = parser.parse_args(["a.txt", "b.txt", "--size", "1920", "1080"])
print(args.files) # ['a.txt', 'b.txt']
print(args.size) # [1920, 1080]
A practical warning: mixing nargs="*" or nargs="+" on an optional argument with positional arguments creates genuine ambiguity. Given --tags a b c file.txt, argparse has no way to know that file.txt is the positional. Users work around it with -- as a separator, but the cleaner fix is to make repeatable options use action="append" instead.
Grouping: mutually exclusive and organised help
Some flags contradict each other. Let argparse enforce it:
group = parser.add_mutually_exclusive_group()
group.add_argument("--quiet", action="store_true")
group.add_argument("--verbose", action="store_true")
Passing both produces error: argument --verbose: not allowed with argument --quiet. Add required=True to the group if exactly one must be chosen.
For long option lists, add_argument_group only affects the help output — it splits options into labelled sections instead of one undifferentiated wall:
output = parser.add_argument_group("output options")
output.add_argument("--format", choices=["json", "csv"])
output.add_argument("--output", "-o", type=Path)
network = parser.add_argument_group("network options")
network.add_argument("--timeout", type=float, default=5.0)
network.add_argument("--retries", type=int, default=3)
Subcommands: the git pattern
This is where argparse earns its place. Tools like git, docker, and pip expose a verb as the first argument, each with its own options. add_subparsers() plus set_defaults() gives you that with clean dispatch and no if/elif chain.
import argparse
import sys
from pathlib import Path
def cmd_upload(args: argparse.Namespace) -> int:
print(f"uploading {args.path} to {args.bucket} (dry_run={args.dry_run})")
return 0
def cmd_list(args: argparse.Namespace) -> int:
print(f"listing {args.bucket}, prefix={args.prefix!r}")
return 0
def build_parser() -> argparse.ArgumentParser:
# Shared flags live in a parent parser, added to every subcommand.
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--bucket", required=True, help="target bucket name")
common.add_argument("--dry-run", action="store_true")
parser = argparse.ArgumentParser(prog="blobtool", description="Manage blob storage.")
subparsers = parser.add_subparsers(dest="command", required=True)
upload = subparsers.add_parser("upload", parents=[common], help="upload a file")
upload.add_argument("path", type=Path)
upload.set_defaults(func=cmd_upload)
listing = subparsers.add_parser("list", parents=[common], help="list objects")
listing.add_argument("--prefix", default="")
listing.set_defaults(func=cmd_list)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
Three things make this pattern work well:
add_help=Falseon the parent. Without it, the parent's own-hcollides with the subparser's and argparse raises a conflict error at import time.required=Trueon the subparsers. Otherwise runningblobtoolwith no arguments succeeds silently andargs.funcdoesn't exist. Explicitly requiring it prints usage and exits.set_defaults(func=...). Each subparser stashes its handler in the namespace, somainis a one-liner instead of a growing dispatch table.
Notice also that main() takes an optional argv. When parse_args() is called with None it falls back to sys.argv[1:], so production behaviour is unchanged — but your tests can call main(["upload", "--bucket", "test", "file.txt"]) directly, with no monkeypatching of sys.argv.
Polishing the help output
Two small changes make generated help noticeably more useful. First, ArgumentDefaultsHelpFormatter appends each default value to its help string, so users don't have to guess:
parser = argparse.ArgumentParser(
description="Sync files to remote storage.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument("--retries", type=int, default=3, help="retry attempts")
# help shows: retry attempts (default: 3)
Second, use RawDescriptionHelpFormatter with an epilog when you want to show examples. By default argparse re-wraps your text and destroys any formatting:
import textwrap
parser = argparse.ArgumentParser(
description="Sync files to remote storage.",
epilog=textwrap.dedent("""\
examples:
sync push ./data --bucket prod
sync pull --bucket prod --prefix logs/
"""),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
You can also suppress an argument from help entirely with help=argparse.SUPPRESS — handy for internal or experimental flags you don't want to advertise.
Common pitfalls
Reporting errors from your own code. If validation depends on more than one argument, don't print() and sys.exit(1). Call parser.error(), which prints usage plus your message and exits with the conventional status 2:
args = parser.parse_args()
if args.output and args.dry_run:
parser.error("--output has no effect with --dry-run")
type=bool does not do what you want. bool("False") is True, because any non-empty string is truthy. Use action="store_true" or BooleanOptionalAction instead.
Mutable defaults. The same rule as function arguments applies. Prefer default=[] at the add_argument call — argparse doesn't reuse the object across parses in practice, but keeping the parser construction inside a function (as in build_parser above) removes the question entirely.
Arguments starting with a dash. Passing --offset -5 confuses the parser, which reads -5 as a flag. Users can write --offset=-5, and it's worth mentioning that form in your help text.
Reaching for parse_known_args() too early. It returns a (namespace, leftovers) tuple and silently swallows unrecognised arguments — useful when forwarding flags to a wrapped tool, but it will happily hide a typo like --verbse for the rest of time.
Wrap-up and next steps
The pattern worth keeping from all of this is small: put parser construction in a build_parser() function, give each subcommand a handler via set_defaults(func=...), share flags through a parent parser with add_help=False, and let main(argv=None) return an exit code. That structure stays readable at five subcommands and at fifty, and it's trivially testable.
From here, three directions are worth exploring. Wire your CLI into a [project.scripts] entry point in pyproject.toml so it installs as a real command instead of python script.py. Pair argparse with the logging module so -v controls verbosity end to end. And if you find yourself writing lots of repetitive add_argument calls that mirror a dataclass, that's the point at which a third-party library like Typer or click starts paying for its dependency — but for the vast majority of scripts, the standard library already has you covered.
]]>