Architecture

argparse Subparsers for Subcommands

Add git-style subcommands to an argparse CLI with add_subparsers, dispatch with set_defaults, share common options, and keep help output clean.

Updated

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 with sub.add_parser("name").
  • Attach a handler to each subparser with set_defaults(func=handler) and dispatch with a single args.func(args) call — no if/elif ladder.
  • 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 in args.command, so you know which one ran.
  • required=True makes running tool with 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 a description=. help= is the one-liner shown in the parent's command list; description= is the paragraph shown on tool build --help. They are different strings and both matter.
  • Set metavar on the subparsers object to control the placeholder in usage text: parser.add_subparsers(dest="command", metavar="COMMAND") prints COMMAND instead 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=True is 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 dest per level. Reusing dest="command" for nested subparsers overwrites the outer value. Name them command, remote_command, and so on.
  • add_help=False on parent parsers, always — otherwise the inherited -h collides.
  • 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.