I was building a CLI tool with five subcommands. Each command needed --verbose and --config. Copy-pasting the same argument definitions felt wrong. There had to be a better way.
There was. Parent parsers. But the docs buried it, and most tutorials skip it entirely.
Here's everything I learned about organizing argparse CLIs—from basic setup to patterns that actually scale.
Quick argparse Refresher
If you've used argparse before, skip ahead. Otherwise, here's the 30-second version:
import argparse
parser = argparse.ArgumentParser(description="Process files")
parser.add_argument("input", help="Input file")
parser.add_argument("-o", "--output", default="out.txt", help="Output file")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
print(f"Processing {args.input} -> {args.output}")$ python tool.py data.csv -o result.json -v
Processing data.csv -> result.jsonThat's the foundation. Now let's build on it.
Subcommands with add_subparsers()
Real CLI tools have commands. Think git commit, docker build, pip install. One entry point, multiple operations.
import argparse
def main():
parser = argparse.ArgumentParser(prog="tasks")
subparsers = parser.add_subparsers(dest="command", required=True)
# 'add' command
add_parser = subparsers.add_parser("add", help="Add a new task")
add_parser.add_argument("title", help="Task title")
add_parser.add_argument("-p", "--priority",
choices=["low", "medium", "high"],
default="medium")
# 'list' command
list_parser = subparsers.add_parser("list", help="List all tasks")
list_parser.add_argument("--status",
choices=["todo", "done", "all"],
default="all")
# 'complete' command
complete_parser = subparsers.add_parser("complete", help="Mark task done")
complete_parser.add_argument("task_id", type=int, help="Task ID")
args = parser.parse_args()
if args.command == "add":
print(f"Adding task: {args.title} (priority: {args.priority})")
elif args.command == "list":
print(f"Listing tasks with status: {args.status}")
elif args.command == "complete":
print(f"Completing task #{args.task_id}")
if __name__ == "__main__":
main()$ tasks add "Write blog post" --priority high
Adding task: Write blog post (priority: high)
$ tasks list --status todo
Listing tasks with status: todo
$ tasks --help
usage: tasks [-h] {add,list,complete} ...
positional arguments:
{add,list,complete}
add Add a new task
list List all tasks
complete Mark task doneKey insight: each subparser is independent. Arguments defined on add_parser don't exist in list_parser. That's good for isolation but creates a problem when you want shared arguments.
The Shared Arguments Problem
What if every command needs --verbose and --config? The naive approach:
# Don't do this
add_parser.add_argument("-v", "--verbose", action="store_true")
add_parser.add_argument("-c", "--config", default="config.json")
list_parser.add_argument("-v", "--verbose", action="store_true")
list_parser.add_argument("-c", "--config", default="config.json")
complete_parser.add_argument("-v", "--verbose", action="store_true")
complete_parser.add_argument("-c", "--config", default="config.json")Three commands, six lines of duplication. Ten commands? You're copying 20 lines that need to stay in sync.
Parent Parsers: The Real Solution
Parent parsers let you define arguments once and inherit them everywhere:
import argparse
def main():
# Create parent parser with shared arguments
parent_parser = argparse.ArgumentParser(add_help=False)
parent_parser.add_argument("-v", "--verbose", action="store_true",
help="Enable verbose output")
parent_parser.add_argument("-c", "--config", default="config.json",
help="Config file path")
# Main parser
parser = argparse.ArgumentParser(prog="tasks")
subparsers = parser.add_subparsers(dest="command", required=True)
# Each subparser inherits from parent
add_parser = subparsers.add_parser("add",
parents=[parent_parser],
help="Add a new task")
add_parser.add_argument("title")
add_parser.add_argument("-p", "--priority", default="medium")
list_parser = subparsers.add_parser("list",
parents=[parent_parser],
help="List all tasks")
list_parser.add_argument("--status", default="all")
complete_parser = subparsers.add_parser("complete",
parents=[parent_parser],
help="Mark task done")
complete_parser.add_argument("task_id", type=int)
args = parser.parse_args()
if args.verbose:
print(f"Config: {args.config}")
# ... rest of command handlingThe magic: parents=[parent_parser]. Every subcommand now has --verbose and --config without repetition.
Note add_help=False on the parent—otherwise you'd get duplicate -h flags.
Multiple Parent Parsers
You can stack parent parsers for different argument groups:
# Shared across all commands
common_parser = argparse.ArgumentParser(add_help=False)
common_parser.add_argument("-v", "--verbose", action="store_true")
common_parser.add_argument("--dry-run", action="store_true")
# Only for commands that connect to a database
db_parser = argparse.ArgumentParser(add_help=False)
db_parser.add_argument("--db-host", default="localhost")
db_parser.add_argument("--db-port", type=int, default=5432)
db_parser.add_argument("--db-name", required=True)
# Only for commands that output data
output_parser = argparse.ArgumentParser(add_help=False)
output_parser.add_argument("-o", "--output", help="Output file")
output_parser.add_argument("-f", "--format", choices=["json", "csv", "table"],
default="table")
# Mix and match
query_parser = subparsers.add_parser("query",
parents=[common_parser, db_parser, output_parser])
migrate_parser = subparsers.add_parser("migrate",
parents=[common_parser, db_parser]) # No output options needed
export_parser = subparsers.add_parser("export",
parents=[common_parser, output_parser]) # No db options neededThis is where argparse starts feeling powerful. Define once, compose freely.
Argument Groups for Organization
Even with parent parsers, --help can become a wall of text. Argument groups add structure:
parser = argparse.ArgumentParser(description="Database migration tool")
# Connection group
conn_group = parser.add_argument_group(
"Connection",
"Database connection settings"
)
conn_group.add_argument("--host", default="localhost")
conn_group.add_argument("--port", type=int, default=5432)
conn_group.add_argument("--database", required=True)
conn_group.add_argument("--user", required=True)
# Auth group
auth_group = parser.add_argument_group("Authentication")
auth_group.add_argument("--password")
auth_group.add_argument("--ssl-cert", metavar="PATH")
auth_group.add_argument("--ssl-key", metavar="PATH")
# Migration group
migration_group = parser.add_argument_group("Migration Options")
migration_group.add_argument("--target", help="Target version")
migration_group.add_argument("--dry-run", action="store_true")
migration_group.add_argument("--no-backup", action="store_true")Help output now has clear sections:
$ migrate --help
usage: migrate [-h] --database DATABASE --user USER ...
Database migration tool
Connection:
Database connection settings
--host HOST (default: localhost)
--port PORT (default: 5432)
--database DATABASE
--user USER
Authentication:
--password PASSWORD
--ssl-cert PATH
--ssl-key PATH
Migration Options:
--target TARGET Target version
--dry-run
--no-backupMuch better than a flat list of 10+ arguments.
Mutually Exclusive Groups
Sometimes arguments conflict. --password vs --token. --json vs --csv. Enforce it:
parser = argparse.ArgumentParser()
# Auth: pick one
auth_group = parser.add_mutually_exclusive_group(required=True)
auth_group.add_argument("--password", help="Authenticate with password")
auth_group.add_argument("--token", help="Authenticate with API token")
auth_group.add_argument("--keyfile", help="Authenticate with SSH key")
# Output format: pick one (optional)
format_group = parser.add_mutually_exclusive_group()
format_group.add_argument("--json", action="store_true")
format_group.add_argument("--csv", action="store_true")
format_group.add_argument("--table", action="store_true")Try using both and argparse stops you:
$ tool --password secret --token abc123
error: argument --token: not allowed with argument --passwordYou can nest these inside argument groups for organization:
auth_section = parser.add_argument_group("Authentication")
auth_options = auth_section.add_mutually_exclusive_group(required=True)
auth_options.add_argument("--password")
auth_options.add_argument("--token")Custom Actions
For complex argument handling, custom actions let you take control:
import argparse
class KeyValueAction(argparse.Action):
"""Parse key=value pairs into a dictionary."""
def __call__(self, parser, namespace, values, option_string=None):
d = getattr(namespace, self.dest, None) or {}
for item in values:
if "=" not in item:
raise argparse.ArgumentError(self, f"Expected KEY=VALUE: {item}")
key, value = item.split("=", 1)
d[key] = value
setattr(namespace, self.dest, d)
class ValidatePathAction(argparse.Action):
"""Validate path exists."""
def __call__(self, parser, namespace, values, option_string=None):
from pathlib import Path
path = Path(values)
if not path.exists():
raise argparse.ArgumentError(self, f"Path not found: {values}")
setattr(namespace, self.dest, path)
parser = argparse.ArgumentParser()
parser.add_argument("-e", "--env", nargs="+", action=KeyValueAction,
default={}, metavar="KEY=VALUE",
help="Environment variables")
parser.add_argument("-c", "--config", action=ValidatePathAction,
help="Config file (must exist)")
args = parser.parse_args(["-e", "DEBUG=1", "PORT=3000", "-c", "config.yaml"])
print(args.env) # {'DEBUG': '1', 'PORT': '3000'}
print(args.config) # PosixPath('config.yaml')Custom actions run during parsing, so errors appear in the standard argparse format with proper exit codes.
Help Formatting and Epilog
Default help formatting crushes your carefully written descriptions. Fix it:
parser = argparse.ArgumentParser(
prog="deploy",
description="Deploy applications to production.",
epilog="""
Examples:
%(prog)s app.zip --env production
%(prog)s --rollback --version 1.2.3
%(prog)s --status
For full documentation: https://docs.example.com/deploy
""",
formatter_class=argparse.RawDescriptionHelpFormatter
)RawDescriptionHelpFormatter preserves your whitespace and line breaks in the description and epilog.
Want to also show default values? Create a combined formatter:
class CustomFormatter(argparse.RawDescriptionHelpFormatter,
argparse.ArgumentDefaultsHelpFormatter):
pass
parser = argparse.ArgumentParser(formatter_class=CustomFormatter)
parser.add_argument("--timeout", type=int, default=30)
# Help shows: --timeout TIMEOUT (default: 30)Putting It All Together
Here's a complete example using everything we covered:
#!/usr/bin/env python3
"""Task management CLI."""
import argparse
import sys
def create_parser():
# Shared arguments
common = argparse.ArgumentParser(add_help=False)
common.add_argument("-v", "--verbose", action="count", default=0,
help="Increase verbosity (-v, -vv, -vvv)")
common.add_argument("-c", "--config", default="~/.tasks.json",
help="Config file path")
# Main parser
parser = argparse.ArgumentParser(
prog="tasks",
description="Manage your tasks from the command line.",
epilog="""
Examples:
%(prog)s add "Buy groceries" --priority high --due tomorrow
%(prog)s list --status todo --format table
%(prog)s complete 42
""",
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--version", action="version", version="%(prog)s 1.0.0")
subparsers = parser.add_subparsers(dest="command", required=True,
metavar="COMMAND")
# --- add command ---
add_parser = subparsers.add_parser("add", parents=[common],
help="Add a new task")
add_parser.add_argument("title", help="Task title")
priority_group = add_parser.add_argument_group("Priority & Scheduling")
priority_group.add_argument("-p", "--priority",
choices=["low", "medium", "high"],
default="medium")
priority_group.add_argument("--due", metavar="DATE",
help="Due date (YYYY-MM-DD or 'tomorrow')")
priority_group.add_argument("--tags", nargs="+", default=[],
help="Tags for the task")
# --- list command ---
list_parser = subparsers.add_parser("list", parents=[common],
help="List tasks")
filter_group = list_parser.add_argument_group("Filters")
filter_group.add_argument("--status", choices=["todo", "done", "all"],
default="all")
filter_group.add_argument("--priority", choices=["low", "medium", "high"])
filter_group.add_argument("--tag", help="Filter by tag")
output_group = list_parser.add_argument_group("Output")
format_opts = output_group.add_mutually_exclusive_group()
format_opts.add_argument("--json", dest="format", action="store_const",
const="json")
format_opts.add_argument("--csv", dest="format", action="store_const",
const="csv")
format_opts.add_argument("--table", dest="format", action="store_const",
const="table")
list_parser.set_defaults(format="table")
# --- complete command ---
complete_parser = subparsers.add_parser("complete", parents=[common],
help="Mark a task as done")
complete_parser.add_argument("task_id", type=int, nargs="+",
help="Task ID(s) to complete")
# --- delete command ---
delete_parser = subparsers.add_parser("delete", parents=[common],
help="Delete a task")
delete_parser.add_argument("task_id", type=int, help="Task ID to delete")
delete_parser.add_argument("--force", action="store_true",
help="Skip confirmation")
return parser
def main(args):
if args.verbose >= 2:
print(f"DEBUG: args = {args}")
if args.command == "add":
print(f"Adding: {args.title}")
print(f" Priority: {args.priority}")
if args.due:
print(f" Due: {args.due}")
if args.tags:
print(f" Tags: {', '.join(args.tags)}")
elif args.command == "list":
print(f"Listing tasks (status={args.status}, format={args.format})")
elif args.command == "complete":
for tid in args.task_id:
print(f"Completed task #{tid}")
elif args.command == "delete":
if not args.force:
print(f"Are you sure you want to delete #{args.task_id}? (use --force)")
else:
print(f"Deleted task #{args.task_id}")
return 0
if __name__ == "__main__":
parser = create_parser()
args = parser.parse_args()
sys.exit(main(args))Test it:
$ tasks add "Write blog post" -p high --tags python argparse
Adding: Write blog post
Priority: high
Tags: python, argparse
$ tasks list --status todo --json
Listing tasks (status=todo, format=json)
$ tasks complete 1 2 3 -vv
DEBUG: args = Namespace(command='complete', config='~/.tasks.json', ...)
Completed task #1
Completed task #2
Completed task #3Common Patterns
A few patterns I use in every CLI:
Separate parser creation from execution. Makes testing easy—just call main() with a mock Namespace.
Use dest for cleaner attribute names. --dry-run becomes args.dry_run automatically, but --no-cache stores to no_cache. Use dest="cache" with action="store_false" for intuitive access.
Return exit codes. sys.exit(main(args)) lets you return 0 for success, 1 for errors, 2 for usage errors.
Set required=True on subparsers. Otherwise running tasks with no command just shows help and exits 0, which can mask errors in scripts.
Parent parsers were the breakthrough for me. Once I understood them, my CLIs went from messy if/else chains to clean, composable structures.
argparse is verbose compared to Click or Typer. But it's in the standard library, it's stable, and it's capable of building professional tools. For most scripts, that's enough.