Every Python CLI tutorial teaches you to add arguments and parse them. Few go further. But argparse has powerful features that make your tools feel professional: subparsers for git-style commands, custom types for validation, and help formatting that actually helps.
Subparsers: Git-Style Commands
If you've used git commit or docker build, you've used subparsers. One entry point, multiple commands, each with its own arguments.
import argparse
def main():
parser = argparse.ArgumentParser(
prog='mytool',
description='A CLI with subcommands'
)
subparsers = parser.add_subparsers(dest='command', required=True)
# 'init' command
init_parser = subparsers.add_parser('init', help='Initialize a new project')
init_parser.add_argument('name', help='Project name')
init_parser.add_argument('--template', default='basic',
choices=['basic', 'full', 'minimal'])
# 'run' command
run_parser = subparsers.add_parser('run', help='Run the project')
run_parser.add_argument('--watch', action='store_true',
help='Watch for changes')
run_parser.add_argument('--port', type=int, default=8000)
args = parser.parse_args()
if args.command == 'init':
print(f"Initializing {args.name} with {args.template} template")
elif args.command == 'run':
print(f"Running on port {args.port}, watch={args.watch}")
if __name__ == '__main__':
main()Run it:
$ mytool init myproject --template full
Initializing myproject with full template
$ mytool run --watch --port 3000
Running on port 3000, watch=True
$ mytool --help
usage: mytool [-h] {init,run} ...
A CLI with subcommands
positional arguments:
{init,run}
init Initialize a new project
run Run the projectThe key insight: each subparser is its own namespace. Arguments don't collide between commands.
Custom Types: Validation at Parse Time
The type parameter isn't just for int and float. It's any callable that takes a string and returns a value—or raises an error.
import argparse
from pathlib import Path
from datetime import datetime
def existing_file(path_str: str) -> Path:
"""Validate that path exists and is a file."""
path = Path(path_str)
if not path.exists():
raise argparse.ArgumentTypeError(f"File not found: {path}")
if not path.is_file():
raise argparse.ArgumentTypeError(f"Not a file: {path}")
return path
def date_type(date_str: str) -> datetime:
"""Parse YYYY-MM-DD format."""
try:
return datetime.strptime(date_str, '%Y-%m-%d')
except ValueError:
raise argparse.ArgumentTypeError(
f"Invalid date: {date_str}. Use YYYY-MM-DD format."
)
def port_number(port_str: str) -> int:
"""Validate port is in valid range."""
port = int(port_str)
if not 1 <= port <= 65535:
raise argparse.ArgumentTypeError(
f"Port must be between 1 and 65535, got {port}"
)
return port
parser = argparse.ArgumentParser()
parser.add_argument('--config', type=existing_file, required=True)
parser.add_argument('--since', type=date_type, help='Start date (YYYY-MM-DD)')
parser.add_argument('--port', type=port_number, default=8080)Now invalid input fails with a helpful message before your code even runs:
$ mytool --config missing.yaml
error: argument --config: File not found: missing.yaml
$ mytool --config /etc --since 2024-01-01
error: argument --config: Not a file: /etcCustom Actions: Complex Argument Handling
For arguments that need more than type conversion, create custom actions:
class KeyValueAction(argparse.Action):
"""Parse --env KEY=VALUE 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, got: {item}"
)
key, value = item.split('=', 1)
d[key] = value
setattr(namespace, self.dest, d)
parser = argparse.ArgumentParser()
parser.add_argument('--env', nargs='+', action=KeyValueAction, default={},
metavar='KEY=VALUE', help='Environment variables')
args = parser.parse_args(['--env', 'DEBUG=1', 'PORT=3000'])
print(args.env) # {'DEBUG': '1', 'PORT': '3000'}Help Formatting: Make It Readable
The default help formatter crams everything together. RawDescriptionHelpFormatter preserves your formatting in descriptions:
parser = argparse.ArgumentParser(
description='''
Process log files and generate reports.
Examples:
%(prog)s --input server.log --format json
%(prog)s --input *.log --since 2024-01-01 --output report.html
''',
formatter_class=argparse.RawDescriptionHelpFormatter
)For even more control, combine formatters:
class CustomFormatter(argparse.RawDescriptionHelpFormatter,
argparse.ArgumentDefaultsHelpFormatter):
"""Show defaults AND preserve description formatting."""
pass
parser = argparse.ArgumentParser(formatter_class=CustomFormatter)
parser.add_argument('--timeout', type=int, default=30,
help='Request timeout in seconds')Now help shows: --timeout TIMEOUT Request timeout in seconds (default: 30)
Argument Groups: Organize Your Options
When you have many arguments, groups add structure:
parser = argparse.ArgumentParser()
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)
auth_group = parser.add_argument_group('Authentication')
auth_group.add_argument('--user', required=True)
auth_group.add_argument('--password')
auth_group.add_argument('--token', help='Use token instead of password')
output_group = parser.add_argument_group('Output')
output_group.add_argument('--format', choices=['json', 'csv', 'table'])
output_group.add_argument('--output', type=argparse.FileType('w'), default='-')Help now shows organized sections instead of a wall of options.
Mutually Exclusive Groups
When options conflict, enforce it:
auth = parser.add_mutually_exclusive_group(required=True)
auth.add_argument('--password', help='Authenticate with password')
auth.add_argument('--token', help='Authenticate with API token')
auth.add_argument('--ssh-key', help='Authenticate with SSH key')Users get a clear error if they try --password secret --token abc123.
The Pattern I Use
Here's my standard structure for non-trivial CLIs:
def create_parser() -> argparse.ArgumentParser:
"""Build the argument parser."""
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter
)
# Add arguments here
return parser
def main(args: argparse.Namespace) -> int:
"""Main entry point. Returns exit code."""
# Business logic here
return 0
if __name__ == '__main__':
parser = create_parser()
args = parser.parse_args()
sys.exit(main(args))Separating parser creation from execution makes testing trivial—you can call main() directly with a mock namespace.
argparse gets you far. For complex CLIs with many commands, consider Click or Typer. But for scripts and simple tools, argparse is already in your Python install and more capable than most developers realize.