Build Real Command-Line Tools with Python's argparse

Learn how to turn a Python script into a polished command-line tool with argparse — positional and optional arguments, types and validation, flags, subcommands, and the pitfalls that trip people up.

Build Real Command-Line Tools with Python's argparse

Every useful script eventually outgrows hard-coded values. You want to point it at a different file, bump a count, or flip on verbose output without editing the source each time. You could reach into sys.argv and parse the list of strings yourself — but you'll quickly reinvent help text, type conversion, error messages, and a dozen edge cases. Python's standard-library argparse module does all of that for you, and it ships with every interpreter, so there's nothing to install.

This guide walks from a minimal parser up to subcommands and shared arguments, with runnable examples at every step. By the end you'll be able to give any script a clean, self-documenting command-line interface.

The smallest useful parser

You create an ArgumentParser, register the arguments you accept, then call parse_args(). Positional arguments are required and identified by position; optional arguments start with - or --. Here's a greeter that takes a name plus a couple of options:

import argparse

parser = argparse.ArgumentParser(
    prog="greet",
    description="Print a friendly greeting.",
)
parser.add_argument("name", help="who to greet")
parser.add_argument("-c", "--count", type=int, default=1,
                    help="how many times to greet")
parser.add_argument("-u", "--upper", action="store_true",
                    help="shout the greeting")

args = parser.parse_args()
for _ in range(args.count):
    msg = f"Hello, {args.name}!"
    print(msg.upper() if args.upper else msg)

Save this as greet.py and try it:

$ python greet.py World
Hello, World!

$ python greet.py World --count 2 --upper
HELLO, WORLD!

$ python greet.py
usage: greet [-h] [-c COUNT] [-u] name
greet: error: the following arguments are required: name

Notice what you got for free: a generated usage: line, a clear error when name is missing, and a -h/--help flag you never wrote. Each argument becomes an attribute on the returned namespace — args.name, args.count, args.upper. The destination name is derived from the long option (--count becomes count), with dashes turned into underscores.

In normal use, don't pass a list to parse_args

When you run a real script, call parser.parse_args() with no arguments and it reads from sys.argv automatically. The examples in this article occasionally pass an explicit list (like parser.parse_args(["World", "-c", "2"])) only so they're self-contained and reproducible — that same trick is also handy in unit tests.

Types, defaults, and validation

The type parameter converts the incoming string and, just as importantly, validates it. If conversion raises, argparse exits with a friendly error instead of a traceback. You can pass any callable, including your own, which is the idiomatic way to enforce constraints:

import argparse

def positive_int(value):
    ivalue = int(value)
    if ivalue <= 0:
        raise argparse.ArgumentTypeError(f"{value} is not a positive integer")
    return ivalue

parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=["fast", "safe", "auto"], default="auto")
parser.add_argument("--files", nargs="+", help="one or more files")
parser.add_argument("--workers", type=positive_int, default=4)
parser.add_argument("-v", "--verbose", action="count", default=0)

args = parser.parse_args(["--mode", "fast", "--files", "a.txt", "b.txt", "-vv"])
print(args.mode, args.files, args.workers, args.verbose)
# fast ['a.txt', 'b.txt'] 4 2

Several powerful features appear here. choices restricts a value to a fixed set and rejects anything else automatically. nargs="+" collects one or more values into a list ("*" means zero or more, and an integer like nargs=2 means exactly two). The count action turns repeated flags into a number, which is the classic way to support -v, -vv, and -vvv verbosity levels.

Boolean flags the modern way

The store_true action is fine for simple on/off switches, but sometimes you want both an explicit "on" and "off" form. Since Python 3.9, BooleanOptionalAction generates a paired --flag / --no-flag automatically:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("--cache", action=argparse.BooleanOptionalAction, default=True)

print(parser.parse_args(["--no-cache"]))  # Namespace(cache=False)
print(parser.parse_args([]))              # Namespace(cache=True)

This is cleaner than maintaining two separate flags, and the help output documents both forms for you.

Mutually exclusive options

Some options don't make sense together — you can't be both quiet and loud. A mutually exclusive group enforces that the user picks at most one:

import argparse

parser = argparse.ArgumentParser()
group = parser.add_mutually_exclusive_group()
group.add_argument("--quiet", action="store_true")
group.add_argument("--loud", action="store_true")

print(parser.parse_args(["--quiet"]))  # Namespace(quiet=True, loud=False)
parser.parse_args(["--quiet", "--loud"])
# error: argument --loud: not allowed with argument --quiet

Subcommands: one tool, many actions

Tools like git and pip expose several verbs (git commit, pip install), each with its own arguments. You build this with add_subparsers(). The cleanest pattern is to attach a handler function to each subparser with set_defaults(func=...), then call args.func(args) after parsing:

import argparse

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

add_p = sub.add_parser("add", help="add a task")
add_p.add_argument("title")
add_p.add_argument("--priority", type=int, default=1)

list_p = sub.add_parser("list", help="list tasks")
list_p.add_argument("--all", action="store_true")

def cmd_add(args):
    print(f"adding {args.title!r} (priority {args.priority})")

def cmd_list(args):
    print(f"listing (all={args.all})")

add_p.set_defaults(func=cmd_add)
list_p.set_defaults(func=cmd_list)

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

Running it:

$ python tool.py add "Buy milk" --priority 3
adding 'Buy milk' (priority 3)

$ python tool.py list --all
listing (all=True)

Passing required=True to add_subparsers ensures the user must choose a command; without it, running the tool with no subcommand would leave args.func undefined. Each subparser also gets its own -h, so python tool.py add -h shows help just for that verb.

Sharing arguments with parent parsers

When several subcommands need the same options — say --config and --verbose — copy-pasting them is error-prone. Define a parent parser with add_help=False and list it in parents:

import argparse

common = argparse.ArgumentParser(add_help=False)
common.add_argument("--config", default="config.toml")
common.add_argument("-v", "--verbose", action="store_true")

main = argparse.ArgumentParser(parents=[common])
main.add_argument("target")

print(main.parse_args(["build", "--verbose"]))
# Namespace(config='config.toml', verbose=True, target='build')

The add_help=False on the parent is essential: without it, both parsers would try to register -h and argparse would raise a conflict error.

Common pitfalls

Forgetting that type only runs on provided strings. If you set default=4 (an int) and the user omits the flag, the default is used as-is and never passes through type. That's usually what you want, but it means a default should already be the correct type.

Mutable defaults. Avoid default=[]. If you need a list default, prefer nargs with no default (you'll get None) and substitute an empty list afterward, or use a sentinel.

Dashes in option names. --dry-run becomes args.dry_run, not args.dry-run. If you want a different attribute name, set dest explicitly.

Reading sys.argv by hand alongside argparse. Let argparse own the whole argument list. Mixing manual parsing with argparse leads to confusing behavior, especially around -- and negative numbers.

Skipping help text. Every add_argument accepts a help string. Filling these in costs seconds and makes --help genuinely useful to your future self.

Wrap-up and next steps

With just add_argument, a handful of actions, and add_subparsers, you can give any script a professional command-line interface — complete with validation, help, and clear error messages — using nothing outside the standard library. Start small: add one or two options to a script you already have, then grow into subcommands as the tool earns them.

From here, a few directions are worth exploring. Look at argparse.FileType for opening files directly from arguments, the metavar and formatter_class options for tidier help output, and environment-variable defaults for configuration. And if you ever find yourself building something large with deeply nested commands and rich output, libraries like click and typer build on these same ideas with extra ergonomics — but argparse will carry you a long way first.