skip to content

How do you give an argparse CLI git-style subcommands and dispatch to the right handler?

level: middleimportance: should knowfreq 45%

answer

  1. One call on the top-level parser
  2. Each subcommand is a parser of its own
  3. Record which name was chosen
  4. Attach the handler to the parser
  5. Shared options need parents=

basics

~10 s

Call parser.add_subparsers(dest="command", required=True), then add_parser("build") on the object it returns for each subcommand, giving each its own arguments. Attach a handler with set_defaults(func=build) so parse_args() leaves args.func ready to call.

solid answer

~40 s

`ArgumentParser.add_subparsers()` returns an object whose `add_parser(name)` builds a **full parser per subcommand**, each with its own arguments and its own generated `-h`. Pass `dest="command"` so the chosen name lands on the `Namespace`, and `required=True` so omitting the subcommand is an error instead of a silently empty parse. The clean dispatch idiom is `set_defaults(func=handler)` on each subparser: after `args = parser.parse_args()`, you call `args.func(args)` with no `if/elif` chain over command names. Arguments declared on the top-level parser must be typed *before* the subcommand name, because once argparse reaches the subcommand it hands the rest of the line to that subparser. Options you want on both sides go in a shared `ArgumentParser(add_help=False)` passed as `parents=[common]` to each.

code

python · 23 lines
python
import argparse

def run_build(args):
    print("building", args.rows, "rows, verbose =", args.verbose)

def run_clean(args):
    print("cleaning caches")

parser = argparse.ArgumentParser(prog="report")
parser.add_argument("--verbose", action="store_true")

sub = parser.add_subparsers(dest="command", required=True)

build = sub.add_parser("build", help="generate the nightly report")
build.add_argument("--rows", type=int, default=6800)
build.set_defaults(func=run_build)

clean = sub.add_parser("clean", help="drop cached batches")
clean.set_defaults(func=run_clean)

args = parser.parse_args(["--verbose", "build", "--rows", "6800"])
print(args.command)
args.func(args)

go deeper

for a junior

Know that a subcommand CLI is parsers inside a parser: add_subparsers() once, then one add_parser() call per verb. Being able to read such a script and say where each argument is declared is enough at this level.

for a middle

Explain the wiring: dest= to record the chosen name, required=True so an empty invocation errors, set_defaults(func=...) for dispatch, and why an option declared on the top-level parser must be typed before the subcommand.

for a senior

Show how you keep the CLI testable and additive: a build_parser() function returning the parser, parse_args() called with an explicit list in tests, and shared options factored through parents= rather than copied into every subparser.

for a principal

Own the interface as a contract. Subcommand names, aliases and flag spellings end up in scripts and schedulers, so decide when a verb is worth adding, how deprecations are announced, and where the line falls between one tool with many verbs and several tools.

### The shape argparse gives you A subcommand CLI — `tool build`, `tool clean`, in the style of version-control and package tools — is built from *nested parsers*. `parser.add_subparsers()` is called once on the top-level parser and returns a special action object. Calling `add_parser("build")` on it creates a brand-new `ArgumentParser` dedicated to that subcommand, which you then configure exactly like any other parser: its own `add_argument()` calls, its own `help=` and `description=`, its own auto-generated `-h`. The top-level `-h` lists the subcommand names; `tool build -h` prints the build parser's own help. ### Knowing which subcommand ran By default nothing on the resulting `Namespace` tells you which subparser matched, so pass `dest="command"` to `add_subparsers()` and the chosen name is stored as `args.command`. Also pass `required=True`: without it, running the tool with no subcommand at all parses successfully and leaves you with a Namespace that has no command, which turns a user mistake into an `AttributeError` deep in your code. With `required=True` argparse prints the usage line and `error: the following arguments are required: command`, exiting with status 2 like every other parse failure. ### Dispatching without an if/elif ladder The idiom worth learning is `set_defaults()`. Each subparser can inject arbitrary values into the Namespace, including a function object: `build_parser.set_defaults(func=run_build)`. After parsing, the whole dispatch is `args.func(args)`. This keeps each subcommand's declaration and its implementation next to each other, removes a chain that grows with every command, and makes adding a subcommand a purely additive change. The handlers take the Namespace so they see both their own arguments and the global ones. A variant passes `**vars(args)` into a handler with matching keyword parameters — cleaner signatures, but it breaks the moment a global argument is added that a handler does not accept, so the Namespace-in form travels better. ### Where global options live An argument declared on the top-level parser is only recognised **before** the subcommand name, because once argparse matches a subcommand it delegates the remainder of the command line to that subparser. So `report --verbose build` works while `report build --verbose` fails, unless `--verbose` is also declared on the build subparser. Users routinely type the second form, so the usual fix is to declare shared options once in a separate `ArgumentParser(add_help=False)` and pass it as `parents=[common]` to the top-level parser and to every subparser; `add_help=False` matters, or the two `-h` definitions collide. That fix has a sharp edge worth knowing about. The subparser is parsed after the top-level parser and writes its own results onto the shared Namespace, so a flag given *before* the subcommand is then overwritten by the subparser's own default: with a plain `store_true` on both, `report --verbose build` comes back with `verbose=False`. Give the shared option `default=argparse.SUPPRESS` and the subparser stores nothing when the flag is absent, leaving the earlier value intact; because the attribute is then missing when nobody passed the flag, read it as `getattr(args, "verbose", False)`. The alternative is to keep global options genuinely global, document that they precede the subcommand, and never duplicate them. ### Sizing and naming Subcommands earn their keep when the operations genuinely take different arguments; if every command takes the same three options, a single parser with a `choices=` argument is simpler and its `-h` is one screen instead of many. `add_parser()` accepts `aliases=["ls"]` for short forms, and `help=` (shown in the top-level listing) is separate from `description=` (shown in the subcommand's own help), which is a distinction people get wrong and then wonder why the parent listing is blank. Subparsers nest arbitrarily — a subparser can call `add_subparsers()` itself — but two levels is already a lot to ask of a user's memory. ### Testing the thing Because `parse_args()` accepts an explicit list, a subcommand CLI is straightforward to test without touching the process argument list: `parser.parse_args(["build", "--rows", "6800"])` returns a Namespace you can assert on, and the `func` default lets you assert that the right handler was selected rather than executing it. Build the parser in a function that returns it, and keep the top-level entry point down to building, parsing and calling `args.func(args)` — that function is then reusable from tests, from another program, and from the module's own `__main__` guard, and nothing about the CLI's structure depends on the process actually being started from a shell.

  • Why does 'report build --verbose' fail when --verbose is declared only on the top-level parser?
    Once argparse matches the subcommand name it delegates the rest of the command line to that subparser, which knows nothing about `--verbose`. Declare shared options in an `ArgumentParser(add_help=False)` and pass `parents=[common]` to both parsers so either position parses. Give that option `default=argparse.SUPPRESS` too, or the subparser's own default overwrites a value the user typed before the subcommand.
  • What breaks if you leave required=True off add_subparsers()?
    Running the tool with no subcommand parses successfully. You get a Namespace with no `command` and no `func`, so the failure surfaces as an `AttributeError` in your dispatch line rather than as a usage message. With `required=True`, argparse prints the usage line and exits 2, the same as any other missing required argument.
  • When are subcommands the wrong structure for a CLI?
    When the operations take the same arguments and differ only in a mode. Then one parser with `choices=["build", "clean"]` is simpler: one help screen, one argument list, no per-subparser duplication. Subcommands pay off when each verb has a genuinely different argument set, which is also when the split help output becomes an advantage rather than a maze.

saying these in an interview costs you the question

  • Dispatches by inspecting sys.argv[1] instead of the Namespace
  • Forgets dest= and cannot tell which subcommand ran
  • Omits required=True and hits AttributeError on empty input
  • Expects top-level options to work after the subcommand name
  • Redeclares every shared option in each subparser by hand
  • Assumes a flag typed before the subcommand cannot be clobbered

context