Skip to content
Codeloom
Python

Python CLI Tools: argparse vs Click vs Typer

Compare Python's top CLI frameworks. Build command-line tools with argparse, Click, and Typer, with examples for arguments, options, subcommands, and validation.

·8 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • Build CLIs with argparse, Click, and Typer
  • Add arguments, options, flags, and subcommands
  • Validate and transform input
  • Choose the right framework for your project

Prerequisites

  • Python functions and decorators
  • Basic command-line usage

Why Build CLI Tools?

Command-line interfaces are the backbone of developer tooling. Scripts that accept arguments, display help text, and validate input are far more useful than scripts with hardcoded values. Python offers three main options for building CLIs:

  • argparse — Built into the standard library, no dependencies
  • Click — Third-party, decorator-based, widely used in production
  • Typer — Built on Click, uses type hints for automatic CLI generation

This guide builds the same tool with each framework so you can compare them directly.

argparse: The Standard Library Option

argparse ships with Python and requires no installation. It is verbose but powerful.

Basic Arguments and Options

import argparse

def main():
    parser = argparse.ArgumentParser(
        description="Process and analyze text files."
    )

    # Positional argument (required)
    parser.add_argument(
        'filename',
        help='Path to the input file'
    )

    # Optional argument with a value
    parser.add_argument(
        '-o', '--output',
        default='result.txt',
        help='Output file path (default: result.txt)'
    )

    # Flag (boolean)
    parser.add_argument(
        '-v', '--verbose',
        action='store_true',
        help='Enable verbose output'
    )

    # Numeric option with type validation
    parser.add_argument(
        '-n', '--lines',
        type=int,
        default=10,
        help='Number of lines to process (default: 10)'
    )

    # Choice from a fixed set
    parser.add_argument(
        '--format',
        choices=['json', 'csv', 'text'],
        default='text',
        help='Output format'
    )

    args = parser.parse_args()

    if args.verbose:
        print(f"Processing {args.filename}")
        print(f"Output: {args.output}")
        print(f"Lines: {args.lines}")
        print(f"Format: {args.format}")

    # Your logic here
    print(f"Would process {args.filename} -> {args.output}")

if __name__ == '__main__':
    main()

Running python script.py --help produces formatted help text automatically.

Subcommands

For tools with multiple operations (like git commit, git push):

import argparse

def handle_init(args):
    print(f"Initializing project: {args.name}")
    if args.template:
        print(f"Using template: {args.template}")

def handle_build(args):
    print(f"Building project (mode: {args.mode})")
    if args.clean:
        print("Cleaning build artifacts first")

def handle_deploy(args):
    print(f"Deploying to {args.target}")

def main():
    parser = argparse.ArgumentParser(description="Project management tool")
    subparsers = parser.add_subparsers(dest='command', help='Available commands')
    subparsers.required = True

    # init subcommand
    init_parser = subparsers.add_parser('init', help='Initialize a new project')
    init_parser.add_argument('name', help='Project name')
    init_parser.add_argument('--template', help='Project template to use')
    init_parser.set_defaults(func=handle_init)

    # build subcommand
    build_parser = subparsers.add_parser('build', help='Build the project')
    build_parser.add_argument(
        '--mode',
        choices=['debug', 'release'],
        default='debug'
    )
    build_parser.add_argument('--clean', action='store_true')
    build_parser.set_defaults(func=handle_build)

    # deploy subcommand
    deploy_parser = subparsers.add_parser('deploy', help='Deploy the project')
    deploy_parser.add_argument(
        'target',
        choices=['staging', 'production']
    )
    deploy_parser.set_defaults(func=handle_deploy)

    args = parser.parse_args()
    args.func(args)

if __name__ == '__main__':
    main()

Custom Validation

import argparse
import pathlib

def valid_file(path_str):
    """Custom type that validates the file exists."""
    path = pathlib.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 port_number(value):
    """Custom type that validates port range."""
    port = int(value)
    if not (1 <= port <= 65535):
        raise argparse.ArgumentTypeError(f"Port must be 1-65535, got {port}")
    return port

parser = argparse.ArgumentParser()
parser.add_argument('input', type=valid_file, help='Input file')
parser.add_argument('--port', type=port_number, default=8080)

Click: The Decorator-Based Framework

Click uses decorators to define commands, which leads to cleaner, more readable code. Install it with pip install click.

Basic Command

import click

@click.command()
@click.argument('filename')
@click.option('-o', '--output', default='result.txt', help='Output file path')
@click.option('-v', '--verbose', is_flag=True, help='Enable verbose output')
@click.option('-n', '--lines', default=10, type=int, help='Number of lines')
@click.option(
    '--format', 'output_format',  # Rename to avoid shadowing built-in
    type=click.Choice(['json', 'csv', 'text']),
    default='text',
    help='Output format'
)
def process(filename, output, verbose, lines, output_format):
    """Process and analyze text files."""
    if verbose:
        click.echo(f"Processing {filename}")
        click.echo(f"Output: {output}")

    click.echo(f"Would process {filename} -> {output} ({output_format})")

if __name__ == '__main__':
    process()

Click Groups (Subcommands)

import click

@click.group()
@click.option('--debug/--no-debug', default=False)
@click.pass_context
def cli(ctx, debug):
    """Project management tool."""
    ctx.ensure_object(dict)
    ctx.obj['DEBUG'] = debug

@cli.command()
@click.argument('name')
@click.option('--template', help='Project template')
@click.pass_context
def init(ctx, name, template):
    """Initialize a new project."""
    if ctx.obj['DEBUG']:
        click.echo("Debug mode is on")
    click.echo(f"Initializing project: {name}")
    if template:
        click.echo(f"Using template: {template}")

@cli.command()
@click.option('--mode', type=click.Choice(['debug', 'release']), default='debug')
@click.option('--clean', is_flag=True)
def build(mode, clean):
    """Build the project."""
    if clean:
        click.echo("Cleaning build artifacts")
    click.echo(f"Building in {mode} mode")

@cli.command()
@click.argument('target', type=click.Choice(['staging', 'production']))
@click.confirmation_option(prompt='Are you sure you want to deploy?')
def deploy(target):
    """Deploy the project to a target environment."""
    click.echo(f"Deploying to {target}")

if __name__ == '__main__':
    cli()

Click Features

Click provides many built-in features that argparse requires manual implementation for:

import click

@click.command()
@click.option('--name', prompt='Your name', help='Your name')
@click.option(
    '--password',
    prompt=True,
    hide_input=True,       # Like getpass
    confirmation_prompt=True  # Ask twice
)
@click.option(
    '--count',
    default=1,
    show_default=True,  # Shows "(default: 1)" in help
    type=click.IntRange(1, 100)  # Built-in range validation
)
@click.option(
    '--color',
    type=click.Choice(['red', 'green', 'blue'], case_sensitive=False)
)
def setup(name, password, count, color):
    """Interactive setup wizard."""
    click.echo(f"Setting up for {name}")
    click.echo(f"Count: {count}")

    # Styled output
    click.secho("Success!", fg='green', bold=True)
    click.secho("Warning: check config", fg='yellow')
    click.secho("Error: something failed", fg='red', err=True)

    # Progress bar
    import time
    items = range(100)
    with click.progressbar(items, label='Processing') as bar:
        for item in bar:
            time.sleep(0.01)

if __name__ == '__main__':
    setup()

File Handling in Click

import click

@click.command()
@click.argument('input', type=click.File('r'))
@click.argument('output', type=click.File('w'))
def convert(input, output):
    """Convert input file to uppercase and write to output."""
    for line in input:
        output.write(line.upper())
    click.echo("Conversion complete!")

# Usage: python script.py input.txt output.txt
# Also supports: python script.py - -  (for stdin/stdout)

if __name__ == '__main__':
    convert()

Typer: Type Hints as CLI Definition

Typer builds on Click but uses Python type hints to define your CLI automatically. Install with pip install typer.

Basic Command

import typer
from pathlib import Path
from typing import Annotated

app = typer.Typer(help="Process and analyze text files.")

@app.command()
def process(
    filename: str,
    output: Annotated[str, typer.Option(help="Output file path")] = "result.txt",
    verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
    lines: Annotated[int, typer.Option(help="Number of lines")] = 10,
    output_format: Annotated[
        str, typer.Option("--format", help="Output format")
    ] = "text",
):
    """Process a text file and generate output."""
    if verbose:
        typer.echo(f"Processing {filename}")
    typer.echo(f"Would process {filename} -> {output}")

if __name__ == '__main__':
    app()

The type hints (str, int, bool) automatically determine how arguments are parsed. A bool parameter becomes a flag, an int gets numeric validation, and so on.

Typer Subcommands

import typer
from typing import Annotated
from enum import Enum

app = typer.Typer(help="Project management tool.")

class BuildMode(str, Enum):
    debug = "debug"
    release = "release"

class DeployTarget(str, Enum):
    staging = "staging"
    production = "production"

@app.command()
def init(
    name: str,
    template: Annotated[str | None, typer.Option(help="Project template")] = None,
):
    """Initialize a new project."""
    typer.echo(f"Initializing project: {name}")
    if template:
        typer.echo(f"Using template: {template}")

@app.command()
def build(
    mode: Annotated[BuildMode, typer.Option()] = BuildMode.debug,
    clean: Annotated[bool, typer.Option()] = False,
):
    """Build the project."""
    if clean:
        typer.echo("Cleaning build artifacts")
    typer.echo(f"Building in {mode.value} mode")

@app.command()
def deploy(
    target: DeployTarget,
):
    """Deploy the project."""
    if target == DeployTarget.production:
        confirm = typer.confirm("Deploy to production?")
        if not confirm:
            raise typer.Abort()
    typer.echo(f"Deploying to {target.value}")

if __name__ == '__main__':
    app()

Typer Advanced Features

import typer
from typing import Annotated
from pathlib import Path

app = typer.Typer()

@app.command()
def analyze(
    # Path validation
    input_file: Annotated[
        Path,
        typer.Argument(
            exists=True,
            file_okay=True,
            dir_okay=False,
            readable=True,
            help="File to analyze"
        )
    ],
    # Multiple values
    tags: Annotated[
        list[str] | None,
        typer.Option(help="Tags to filter by")
    ] = None,
    # Numeric range
    threshold: Annotated[
        float,
        typer.Option(min=0.0, max=1.0, help="Score threshold")
    ] = 0.5,
):
    """Analyze a file with optional filtering."""
    typer.echo(f"Analyzing: {input_file}")
    typer.echo(f"Threshold: {threshold}")
    if tags:
        typer.echo(f"Tags: {', '.join(tags)}")

    # Rich output with colors
    typer.secho("Analysis complete!", fg=typer.colors.GREEN, bold=True)

    # Progress
    import time
    with typer.progressbar(range(100), label="Processing") as progress:
        for _ in progress:
            time.sleep(0.01)

if __name__ == '__main__':
    app()

Side-by-Side Comparison

Here is the same simple tool in all three frameworks:

# === argparse ===
import argparse

parser = argparse.ArgumentParser(description="Greet someone")
parser.add_argument('name', help='Name to greet')
parser.add_argument('--greeting', default='Hello', help='Greeting to use')
parser.add_argument('--shout', action='store_true', help='SHOUT the greeting')
args = parser.parse_args()

message = f"{args.greeting}, {args.name}!"
print(message.upper() if args.shout else message)
# === Click ===
import click

@click.command()
@click.argument('name')
@click.option('--greeting', default='Hello', help='Greeting to use')
@click.option('--shout', is_flag=True, help='SHOUT the greeting')
def greet(name, greeting, shout):
    """Greet someone."""
    message = f"{greeting}, {name}!"
    click.echo(message.upper() if shout else message)

if __name__ == '__main__':
    greet()
# === Typer ===
import typer
from typing import Annotated

def greet(
    name: str,
    greeting: Annotated[str, typer.Option(help="Greeting to use")] = "Hello",
    shout: Annotated[bool, typer.Option(help="SHOUT the greeting")] = False,
):
    """Greet someone."""
    message = f"{greeting}, {name}!"
    typer.echo(message.upper() if shout else message)

if __name__ == '__main__':
    typer.run(greet)

When to Use Each Framework

FeatureargparseClickTyper
Install requiredNoYesYes
Code styleImperativeDecoratorsType hints
Learning curveMediumLowLowest
SubcommandsVerboseCleanClean
TestingManualBuilt-in CliRunnerBuilt-in CliRunner
Prompts/colorsManualBuilt-inBuilt-in
Shell completionNoPluginBuilt-in

Choose argparse when you cannot add dependencies or need fine-grained control over parsing behavior.

Choose Click when you want a battle-tested framework with excellent documentation and a large ecosystem of plugins.

Choose Typer when you want the fastest path from function signature to working CLI. It is the most Pythonic option if you already use type hints throughout your codebase.

Wrapping Up

All three frameworks produce professional CLIs with help text, validation, and subcommands. The main difference is how you define the interface: argparse uses an imperative builder pattern, Click uses decorators, and Typer uses type annotations. For new projects, Typer offers the best developer experience with the least boilerplate. For projects that cannot take on dependencies, argparse remains a solid choice. Pick the one that fits your team’s style and dependency policy, and focus on building the actual tool logic.