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.json

That'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 done

Key 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 handling

The 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 needed

This 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-backup

Much 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 --password

You 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 #3

Common 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.

React to this post: