Build Real Command-Line Tools: A Practical Deep Dive into Python's argparse

Learn to build polished command-line tools with Python's argparse — positional and optional arguments, types, choices, nargs, count/append actions, mutually exclusive groups, subcommands, and the pitfalls that trip people up.

Build Real Command-Line Tools: A Practical Deep Dive into Python's argparse

Sooner or later every useful script needs to accept input from the command line: a filename to process, a --verbose flag, a --count option. You can pull those out of sys.argv by hand, but you'll quickly find yourself reinventing help text, type conversion, and error messages — badly. Python's standard-library argparse module does all of that for you, and it ships with every interpreter. This guide walks through the pieces you'll actually use, with runnable examples and the gotchas that catch people out.

Why not just read sys.argv?

Parsing arguments manually looks deceptively simple until you need to support optional flags in any order, convert strings to integers, validate choices, and print a usage message when someone gets it wrong. argparse gives you all of that from a declarative description of what your program accepts. Consider a small greeting tool:

import argparse

parser = argparse.ArgumentParser(
    prog="greet",
    description="Greet someone a number of times.",
)
parser.add_argument("name", help="who to greet")
parser.add_argument("-c", "--count", type=int, default=1,
                    help="how many times (default: 1)")
parser.add_argument("-v", "--verbose", action="store_true",
                    help="print extra detail")

args = parser.parse_args()
if args.verbose:
    print(f"[greeting {args.name} {args.count} times]")
for _ in range(args.count):
    print(f"Hello, {args.name}!")

Run it as python greet.py Ada --count 3 -v and you get three greetings plus the verbose line. Run it with no arguments and argparse prints a clear error telling you name is required. Run it with -h and you get a formatted help screen — for free, generated from the help= strings you supplied.

Positional vs. optional arguments

The distinction is the single most important idea in argparse. A positional argument has a bare name ("name") and is required by position. An optional argument starts with a dash ("-c", "--count") and is, as the name suggests, usually optional. The result of parsing is a plain Namespace object whose attributes are named after your arguments — with dashes converted to underscores, so --dry-run becomes args.dry_run.

parser.add_argument("source")               # args.source
parser.add_argument("--output-dir")         # args.output_dir
parser.add_argument("-n", "--num-workers")  # args.num_workers

When you give both a short and long flag, argparse uses the long name (minus the leading dashes) for the attribute. Keep the long form descriptive; it's what shows up in help.

Types, defaults, and choices

Command-line input is always text. The type= parameter converts it for you, and any callable that takes one string works — including int, float, pathlib.Path, or your own function. Pair it with default= for values the user can omit, and choices= to restrict input to a fixed set:

import argparse

p = argparse.ArgumentParser()
p.add_argument("--mode", choices=["fast", "safe"], default="safe")
p.add_argument("--level", type=int, choices=range(1, 4),
               metavar="{1,2,3}", default=1)

args = p.parse_args(["--mode", "fast", "--level", "2"])
print(args.mode, args.level)   # fast 2

If the user passes --mode reckless, argparse rejects it and lists the valid choices. The metavar above controls how the option's value is displayed in help and error messages — handy when choices=range(1, 4) would otherwise print an ugly range object.

Controlling how many values: nargs

By default an argument consumes exactly one value. nargs changes that. Use "+" for one-or-more, "*" for zero-or-more, a fixed integer for an exact count, or "?" for an optional single value:

import argparse

p = argparse.ArgumentParser()
p.add_argument("files", nargs="+", help="one or more input files")
args = p.parse_args(["a.txt", "b.txt", "c.txt"])
print(args.files)   # ['a.txt', 'b.txt', 'c.txt']

The "?" form is subtle but useful. Combined with const, it lets a flag work three ways: absent, present-without-a-value, and present-with-a-value:

p = argparse.ArgumentParser()
p.add_argument("--log", nargs="?", const="app.log", default=None)

print(p.parse_args([]).log)                 # None      (flag absent)
print(p.parse_args(["--log"]).log)          # app.log   (flag, no value -> const)
print(p.parse_args(["--log", "x.log"]).log) # x.log     (flag with value)

Actions: flags, counting, and accumulating

The action parameter decides what happens when an argument appears. "store_true" and "store_false" make boolean flags. "count" tallies repeats — the classic way to support -v, -vv, -vvv for escalating verbosity. "append" collects repeated options into a list:

import argparse

p = argparse.ArgumentParser()
p.add_argument("-v", "--verbose", action="count", default=0)
p.add_argument("--tag", action="append", default=[])

args = p.parse_args(["-vvv", "--tag", "urgent", "--tag", "backend"])
print(args.verbose)   # 3
print(args.tag)       # ['urgent', 'backend']

Note the explicit default=0 and default=[]. Without them, count defaults to None and append leaves the attribute as None when the flag is never used, which tends to break the code that reads it.

Mutually exclusive options

Some flags shouldn't be combined — you can't output both JSON and YAML at once. A mutually exclusive group enforces that at parse time:

import argparse

p = argparse.ArgumentParser()
group = p.add_mutually_exclusive_group()
group.add_argument("--json", action="store_true")
group.add_argument("--yaml", action="store_true")

print(p.parse_args(["--json"]))   # Namespace(json=True, yaml=False)
# p.parse_args(["--json", "--yaml"]) -> error: not allowed together

Subcommands: building a git-style CLI

Tools like git, pip, and docker dispatch to subcommands, each with its own arguments. add_subparsers models exactly this. The common pattern is to attach a handler function to each subparser with set_defaults(func=...), then call it after parsing:

import argparse

parser = argparse.ArgumentParser(prog="tool")
sub = parser.add_subparsers(dest="command", required=True)

p_add = sub.add_parser("add", help="add two numbers")
p_add.add_argument("x", type=float)
p_add.add_argument("y", type=float)
p_add.set_defaults(func=lambda a: print(a.x + a.y))

p_greet = sub.add_parser("greet", help="greet a person")
p_greet.add_argument("name")
p_greet.set_defaults(func=lambda a: print(f"Hi {a.name}"))

args = parser.parse_args()
args.func(args)

Now tool add 2 3 prints 5.0 and tool greet Sam prints Hi Sam. Each subcommand even gets its own -h help. Passing required=True to add_subparsers ensures the user must choose a command rather than silently doing nothing.

Custom validation with a type function

Because type= accepts any callable, you can validate as you convert. Raise argparse.ArgumentTypeError to produce a clean, argparse-formatted error instead of an ugly traceback:

import argparse

def even(value):
    n = int(value)
    if n % 2:
        raise argparse.ArgumentTypeError(f"{n} is not even")
    return n

p = argparse.ArgumentParser()
p.add_argument("--n", type=even)

print(p.parse_args(["--n", "4"]).n)   # 4
# p.parse_args(["--n", "5"])  ->  error: argument --n: 5 is not even

Common pitfalls

Forgetting defaults for append and count. Both default to None, which surprises code expecting a list or a number. Always set default=[] or default=0 explicitly.

Assuming store_true defaults to something other than False. A store_true flag is False when absent and True when present — that's it. Use store_false when you want the inverse polarity (present means "turn this off").

Testing by calling parse_args() with a list. In real programs you call parser.parse_args() with no arguments and it reads sys.argv. But you can pass an explicit list — parser.parse_args(["Ada", "-v"]) — which makes the parser trivial to unit-test without touching the actual command line.

Not knowing argparse calls sys.exit() on error. When parsing fails, argparse prints usage and exits with status 2. That's the right behavior for a CLI, but in tests you'll want to catch SystemExit.

Wrap-up and next steps

With just add_argument and a handful of parameters — type, default, choices, nargs, and action — you can build command-line tools that feel professional: helpful error messages, auto-generated help, type conversion, and validation, all declared in a few lines. Add add_subparsers when your tool grows multiple commands, and lean on custom type functions for domain-specific validation. From here, explore argparse's parents= feature for sharing arguments across subcommands, look at argparse.FileType for opening files directly, and if you outgrow the standard library, evaluate third-party options like Click and Typer that build richer CLIs on the same ideas.