argparse Patterns for Real CLI Tools
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'srequired=Truedoing its job. Add a helpful top-leveldescription.- A subcommand can't see
--verbose— you didn't passparents=[common]toadd_parser, or you forgotadd_help=Falseon the parent (two parsers both defining-hcollide). --flag valueworks but--flag=valuedoesn't for anappendaction — both should work; if not, you likely definednargswhere 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 justprintand fall off the end. - Argument order matters unexpectedly — options after a positional can be
consumed by
nargs="*". Putnargspositionals last, or use--.
Related tools and reading¶
- On-site: Regex Tester, JWT Decoder.
- Related posts: Packaging a Python CLI, subprocess without shell injection.
Stuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.More in Python
asyncio for I/O-Bound Scripts
Async Python shines for hundreds of concurrent network calls and nothing else. Here's a correct pattern for a bulk-fetch script, with concurrency limits and error handling.
October 24, 2026Python Type Hints That Actually Pay Off
Type hints earn their keep at function boundaries and data models. Here's a pragmatic subset, a CI setup, and how to adopt them on an untyped codebase.
October 24, 2026Packaging a Python CLI
Turn a script into a `pip install`-able command: the pyproject.toml, the entry point, the src layout, and how users should install it.
October 24, 2026