Once a stdlib CLI does more than one thing — tool build, tool deploy, tool clean — you
want git-style subcommands, each with its own arguments and help page. argparse handles
this with subparsers: a special group where each entry is a full parser in its own right.
This guide builds a clean, dispatchable subcommand tree with no third-party dependencies, and
shows the set_defaults(func=...) pattern that keeps the routing tidy.
TL;DR
- Create the group with
parser.add_subparsers(dest="command", required=True), then add one parser per subcommand withsub.add_parser("name"). - Attach a handler to each subparser with
set_defaults(func=handler)and dispatch with a singleargs.func(args)call — noif/elifladder. - Share options across subcommands with a parent parser passed via
parents=[...]. - Nest subcommands by giving a subparser its own
add_subparsers(). - This is the manual version of what Click groups do for free.
The core mechanism: add_subparsers
add_subparsers() turns one positional slot into a switch over named subparsers. Each
subparser is a normal ArgumentParser you configure independently.
# tool.py
import argparse
def main() -> None:
parser = argparse.ArgumentParser(prog="tool", description="Project tool.")
subparsers = parser.add_subparsers(dest="command", required=True)
build = subparsers.add_parser("build", help="Build the project.")
build.add_argument("--release", action="store_true", help="Optimized build.")
deploy = subparsers.add_parser("deploy", help="Deploy the project.")
deploy.add_argument("target", help="Environment to deploy to.")
args = parser.parse_args()
print(args)
if __name__ == "__main__":
main()
$ python tool.py build --release
Namespace(command='build', release=True)
$ python tool.py deploy prod
Namespace(command='deploy', target='prod')
Two arguments to add_subparsers() matter most:
dest="command"stores the chosen subcommand name inargs.command, so you know which one ran.required=Truemakes runningtoolwith no subcommand an error instead of silently doing nothing. On Python 3, subparsers are optional by default — always set this explicitly, or a bare invocation falls through to code that assumes a command was given.
Dispatching with set_defaults(func=...)
Reading args.command and branching with if command == "build": ... elif ... works but
rots as commands multiply. The idiomatic argparse pattern is to attach the handler function
to each subparser with set_defaults, then call whatever landed in args.func.
# tool.py
import argparse
def cmd_build(args: argparse.Namespace) -> int:
print(f"Building (release={args.release})")
return 0
def cmd_deploy(args: argparse.Namespace) -> int:
print(f"Deploying to {args.target}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(prog="tool", description="Project tool.")
subparsers = parser.add_subparsers(dest="command", required=True)
build = subparsers.add_parser("build", help="Build the project.")
build.add_argument("--release", action="store_true")
build.set_defaults(func=cmd_build)
deploy = subparsers.add_parser("deploy", help="Deploy the project.")
deploy.add_argument("target")
deploy.set_defaults(func=cmd_deploy)
args = parser.parse_args()
return args.func(args) # dispatch: no if/elif needed
if __name__ == "__main__":
raise SystemExit(main())
set_defaults(func=cmd_build) injects func into the namespace only when that subparser is
selected, so args.func is always the right handler. Adding a fifth or fiftieth command
never touches the dispatch line — you write a handler and one set_defaults. Returning an
int from each handler and passing it to SystemExit gives you real
exit codes
for callers and CI.
Because required=True guarantees a subcommand, args.func always exists. If you ever set
required=False on purpose, guard with if not hasattr(args, "func"): parser.print_help().
Sharing options with a parent parser
Several subcommands often need the same flags — --verbose, --config. Declaring them on
each subparser is duplication; declaring them on the top-level parser puts them before the
subcommand (tool --verbose build), which is not always what you want. The clean fix is a
parent parser: a parser marked add_help=False whose arguments are inherited via
parents=[...].
import argparse
# Shared options live once, in a parent that adds no help of its own.
common = argparse.ArgumentParser(add_help=False)
common.add_argument("-v", "--verbose", action="store_true", help="Verbose output.")
common.add_argument("--config", default="config.toml", help="Config file path.")
parser = argparse.ArgumentParser(prog="tool")
subparsers = parser.add_subparsers(dest="command", required=True)
build = subparsers.add_parser("build", parents=[common], help="Build the project.")
build.add_argument("--release", action="store_true")
deploy = subparsers.add_parser("deploy", parents=[common], help="Deploy.")
deploy.add_argument("target")
Now both tool build --verbose and tool deploy prod --verbose --config prod.toml work, and
--verbose/--config are defined in exactly one place. add_help=False on the parent is
essential — without it, both the parent and child try to add -h, and argparse raises an
"conflicting option string" error at startup.
Nested subcommands
A subparser can host its own subparsers, giving you tool remote add .... Give the
intermediate subparser a fresh add_subparsers() and attach children to it:
import argparse
parser = argparse.ArgumentParser(prog="tool")
top = parser.add_subparsers(dest="command", required=True)
remote = top.add_parser("remote", help="Manage remotes.")
remote_sub = remote.add_subparsers(dest="remote_command", required=True)
add = remote_sub.add_parser("add", help="Add a remote.")
add.add_argument("url")
add.set_defaults(func=lambda a: print(f"Added {a.url}"))
rm = remote_sub.add_parser("remove", help="Remove a remote.")
rm.add_argument("name")
rm.set_defaults(func=lambda a: print(f"Removed {a.name}"))
args = parser.parse_args()
args.func(args)
$ python tool.py remote add https://example.com/reg
Added https://example.com/reg
Use a distinct dest at each level (command, then remote_command) so the two choices
don't overwrite each other in the namespace. The set_defaults(func=...) dispatch keeps
working no matter how deep you nest, because the innermost selected subparser wins. In
practice two levels is the usability ceiling — beyond that, help output and muscle memory
both suffer.
Keeping help output clean
Subparsers can make --help noisy. A few habits keep it readable:
- Give every subparser a
help=and adescription=.help=is the one-liner shown in the parent's command list;description=is the paragraph shown ontool build --help. They are different strings and both matter. - Set
metavaron the subparsers object to control the placeholder in usage text:parser.add_subparsers(dest="command", metavar="COMMAND")printsCOMMANDinstead of the auto-generated{build,deploy,clean}brace list, which gets unwieldy past a few commands. - Group related flags with
parser.add_argument_group("output options")inside a busy subparser so its help splits into labeled sections.
subparsers = parser.add_subparsers(
dest="command", required=True, metavar="COMMAND",
title="commands", help="Run 'tool COMMAND --help' for details.",
)
With title and metavar set, the top-level help reads like a real command index instead of
a brace-list dump:
$ python tool.py --help
usage: tool [-h] COMMAND ...
Project tool.
options:
-h, --help show this help message and exit
commands:
COMMAND Run 'tool COMMAND --help' for details.
build Build the project.
deploy Deploy the project.
Each subcommand's own -h then shows its description and arguments, so users discover the
tree one level at a time — the same progressive-disclosure shape a
Click group
gives you automatically.
Aliases and mutually exclusive options
Two features round out a realistic subcommand tree. add_parser accepts aliases, so
tool rm can be a shorthand for tool remove without a second handler:
rm = remote_sub.add_parser("remove", aliases=["rm"], help="Remove a remote.")
Both tool remote remove foo and tool remote rm foo now route to the same subparser and
the same func. Within a subcommand you often want "exactly one of these flags"; a mutually
exclusive group enforces that at parse time:
push = subparsers.add_parser("push", help="Push changes.")
mode = push.add_mutually_exclusive_group(required=True)
mode.add_argument("--force", action="store_true", help="Overwrite remote history.")
mode.add_argument("--safe", action="store_true", help="Refuse on conflict.")
$ python tool.py push --force --safe
tool push: error: argument --safe: not allowed with argument --force
argparse rejects the illegal combination itself, with a clear message, so your handler
never has to validate that --force and --safe weren't both passed. required=True on the
group makes supplying neither an error too.
A default subcommand
Sometimes running the bare tool should behave like one of the subcommands — tool alone
acting as tool status. Keep required=False, then fall back when no func was set:
args = parser.parse_args()
handler = getattr(args, "func", cmd_status) # default to status
raise SystemExit(handler(args))
This is the one case where you deliberately leave the subparsers group optional. Everywhere
else, required=True and an explicit error is the safer default — a silent no-op confuses
users who fat-fingered a command name.
Testing subcommand dispatch
Because parsing is separate from the handlers, you can test both cheaply. Feed an explicit
argument list to parse_args — it only reads sys.argv when you pass nothing — and assert
that the right handler and values landed in the namespace.
# test_tool.py
from tool import build_parser # factor the parser into a function that returns it
def test_build_routes_to_handler() -> None:
args = build_parser().parse_args(["build", "--release"])
assert args.command == "build"
assert args.release is True
assert args.func.__name__ == "cmd_build"
def test_missing_subcommand_errors() -> None:
import pytest
with pytest.raises(SystemExit): # required=True exits on no subcommand
build_parser().parse_args([])
Refactoring parser construction into a build_parser() -> argparse.ArgumentParser function
(instead of building it inline in main()) is what makes this testable — do it early.
Production notes
required=Trueis not the default. The single most common argparse subcommand bug is a bare invocation silently doing nothing because the subparsers group was optional. Always set it, and test the empty-args case.- Distinct
destper level. Reusingdest="command"for nested subparsers overwrites the outer value. Name themcommand,remote_command, and so on. add_help=Falseon parent parsers, always — otherwise the inherited-hcollides.- This scales to a point. Manual subparsers are fine for a dozen commands. When you find
yourself wanting shared context objects, lazy loading for startup time, or completion, the
hand-rolled version stops paying off. The Click equivalent —
building a CLI with subcommands in Click
— gives you groups,
ctx.obj, and completion out of the box, and migrating from argparse to Typer maps subparsers directly onto Typer commands.