Server

argparse Patterns for Real CLI Tools

By Prabath Thalangama· October 24, 2026· 3 min read
#python#automation#troubleshooting

Introduction

For an operational script — something that wraps an API, tidies up logs, or runs a deploy step — argparse from the standard library is usually enough. The trick is knowing a few patterns so you don't reinvent them badly. This post collects the ones that come up again and again.

Concepts

argparse builds a parser, you call parse_args(), and you get a Namespace object with your options as attributes. The two things worth internalising:

  • add_subparsers() turns one tool into many (tool add, tool remove), each with its own options and its own function.
  • Parent parsers let subcommands share options (--verbose, --config) without repeating the definitions.

Hands-on

Subcommands with a dispatch function

import argparse
import sys


def cmd_deploy(args: argparse.Namespace) -> int:
    print(f"deploying {args.service} to {args.env}")
    return 0


def cmd_rollback(args: argparse.Namespace) -> int:
    print(f"rolling back {args.service} in {args.env}")
    return 0


def build_parser() -> argparse.ArgumentParser:
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--env", default="staging", choices=["staging", "prod"])
    common.add_argument("-v", "--verbose", action="store_true")

    parser = argparse.ArgumentParser(prog="deployer", description="Deploy things.")
    sub = parser.add_subparsers(dest="command", required=True)

    p_deploy = sub.add_parser("deploy", parents=[common], help="deploy a service")
    p_deploy.add_argument("service")
    p_deploy.set_defaults(func=cmd_deploy)

    p_rollback = sub.add_parser("rollback", parents=[common], help="roll a service back")
    p_rollback.add_argument("service")
    p_rollback.set_defaults(func=cmd_rollback)

    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())

set_defaults(func=...) on each subparser plus args.func(args) in main is the cleanest dispatch — no big if args.command == ... chain. required=True on the subparsers makes bare deployer print usage and exit non-zero.

Environment-variable defaults

Operational tools often read config from the environment. Fold it into the default so the flag still wins:

import os

parser.add_argument(
    "--token",
    default=os.environ.get("DEPLOY_TOKEN"),
    help="API token (or set DEPLOY_TOKEN)",
)
parser.add_argument(
    "--region",
    default=os.environ.get("AWS_REGION", "eu-west-1"),
)

Then validate after parsing so the error message is yours, not a traceback:

args = parser.parse_args()
if not args.token:
    parser.error("no token: pass --token or set DEPLOY_TOKEN")

parser.error() prints usage, your message, and exits with status 2 — the conventional "misuse" code.

Mutually exclusive flags

group = parser.add_mutually_exclusive_group()
group.add_argument("--start", action="store_true")
group.add_argument("--stop", action="store_true")

Repeatable options and key=value pairs

parser.add_argument("--label", action="append", default=[], metavar="KEY=VALUE")
# deployer deploy web --label team=payments --label tier=1

A --version flag

parser.add_argument("--version", action="version", version="%(prog)s 2.3.1")

Verification and troubleshooting

Test the parser directly — no subprocess needed:

def test_deploy_defaults_to_staging():
    args = build_parser().parse_args(["deploy", "web"])
    assert args.env == "staging"
    assert args.func is cmd_deploy


def test_bad_env_is_rejected(capsys):
    with pytest.raises(SystemExit):
        build_parser().parse_args(["deploy", "web", "--env", "chaos"])

Common issues:

  • error: the following arguments are required: command — good, that's required=True doing its job. Add a helpful top-level description.
  • A subcommand can't see --verbose — you didn't pass parents=[common] to add_parser, or you forgot add_help=False on the parent (two parsers both defining -h collide).
  • --flag value works but --flag=value doesn't for an append action — both should work; if not, you likely defined nargs where you didn't mean to.
  • Exit code is always 0 even on failure — return an int from each command function and sys.exit(main()); don't just print and fall off the end.
  • Argument order matters unexpectedly — options after a positional can be consumed by nargs="*". Put nargs positionals last, or use --.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.