Tutorial: Learn Python CLI with Click from Scratch (2026)
I've written dozens of CLI tools in Python — for data processing, deployment automation, code generation, and system administration. Python's standard library argparse works, but Click makes building CLI applications feel natural. Click uses decorators to define commands, arguments, options, and parameter validation. It auto-generates help text, handles type conversion, and supports nested command groups without boilerplate.
This tutorial covers Click's core concepts through a practical CLI for managing a task database. You'll see commands with arguments (positional values), options (flags with values), parameter types, error handling, and multi-command groups. By the end you'll be able to ship a polished CLI tool with colored output, progress bars, and configuration files.
Commands and the Click Decorator Pattern
The @click.command() decorator turns a function into a CLI command. The function name becomes the command name. Click automatically generates --help output from your parameter definitions. The command function receives parsed arguments as Python parameters. Running the script directly requires the standard if __name__ block with the command function as the entry point.
import click
@click.command()
@click.option('--name', default='World', help='Who to greet')
@click.option('--count', default=1, type=int, help='Number of times')
def greet(name, count):
for _ in range(count):
click.echo(f"Hello, {name}!")
if __name__ == '__main__':
greet()
# Usage:
# python greet.py --name Alice --count 3
# python greet.py --help
Arguments vs Options
Arguments are positional values required in order. Options are named parameters prefixed with -- or -. Use @click.argument() for required positional input like filenames or identifiers. Use @click.option() for optional flags, configuration values, or switches. Arguments are positional and required by default; options are optional unless you set required=True. I reserve arguments for the primary input and options for modifiers.
@click.command()
@click.argument('input_file', type=click.Path(exists=True))
@click.argument('output_file', type=click.Path())
@click.option('--format', default='json', type=click.Choice(['json', 'csv']))
@click.option('--verbose', is_flag=True, help='Enable verbose output')
@click.option('--limit', default=100, type=int, help='Max records')
def convert(input_file, output_file, format, verbose, limit):
"""Convert INPUT_FILE to OUTPUT_FILE in specified format."""
if verbose:
click.echo(f"Converting {input_file} to {format} (limit={limit})")
# Processing logic
if __name__ == '__main__':
convert()
# Usage: python convert.py data.csv output.json --format json --verbose
Parameter Types and Validation
Click provides built-in types: str, int, float, bool, click.Path, click.Choice, click.IntRange, click.FloatRange, and click.DateTime. It validates input automatically and shows clear error messages. Custom types are created by subclassing click.ParamType and implementing convert(). Callbacks can also validate or transform values with the callback parameter on options.
import datetime
class EmailType(click.ParamType):
name = 'email'
def convert(self, value, param, ctx):
if '@' not in value:
self.fail(f'{value} is not a valid email', param, ctx)
return value.lower()
@click.command()
@click.option('--email', type=EmailType(), required=True)
@click.option('--age', type=click.IntRange(0, 150))
@click.option('--role', type=click.Choice(['admin', 'user', 'viewer']))
@click.option('--joined', type=click.DateTime(formats=['%Y-%m-%d']))
def register(email, age, role, joined):
click.echo(f"Registering {email} as {role}")
if __name__ == '__main__':
register()
Command Groups and Subcommands
Complex CLIs organize commands into groups — like git commit, git push, git log. @click.group() creates a group, and subcommands are added with @group.command(). The group passes shared context via @click.pass_context, and you can define common options at the group level that apply to all subcommands. I use this pattern for CLIs that manage multiple resource types (users, projects, tasks).
@click.group()
@click.option('--config', default='config.yaml', help='Config file')
@click.pass_context
def cli(ctx, config):
"""Task Manager CLI"""
ctx.ensure_object(dict)
ctx.obj['config'] = config
@cli.command()
@click.argument('title')
@click.option('--priority', default='medium')
@click.pass_context
def add(ctx, title, priority):
"""Add a new task"""
click.echo(f"Added task: {title} (priority={priority})")
@cli.command()
@click.option('--status', default='all')
@click.pass_context
def list(ctx, status):
"""List tasks"""
click.echo(f"Listing tasks with status={status}")
if __name__ == '__main__':
cli()
# Usage: python tasks.py add "Buy groceries" --priority high
# python tasks.py list --status pending
Colors, Formatting, and Progress Bars
Click's click.echo() supports ANSI colors with the style() utility. Progress bars use click.progressbar() which wraps any iterable. For formatted tables, I use click.secho() for colored output. These visual touches make CLIs feel polished and user-friendly. The color support works on Windows, macOS, and Linux without additional dependencies.
import time
@click.command()
@click.option('--name', default='World')
@click.option('--no-color', is_flag=True)
def greet(name, no_color):
if no_color:
click.echo(f"Hello, {name}!")
else:
click.secho(f"Hello, {name}!", fg='green', bold=True)
click.secho("Welcome to the CLI", fg='blue')
@click.command()
def process():
items = list(range(100))
with click.progressbar(items, label='Processing') as bar:
for item in bar:
time.sleep(0.02) # simulate work
@click.command()
def table():
headers = ['Name', 'Age', 'City']
rows = [
['Alice', '30', 'NYC'],
['Bob', '25', 'SF'],
['Charlie', '35', 'Chicago'],
]
# Tabulate (install: pip install tabulate)
from tabulate import tabulate
click.echo(tabulate(rows, headers=headers, tablefmt='grid'))
Error Handling and Exit Codes
Click provides click.ClickException for user-facing errors with proper exit codes. Use click.UsageError for invalid argument combinations. For unexpected errors, Click exits with code 1 by default. Custom exception handlers can clean up resources before exiting. The ctx.exit() method exits with a specific code. I use exception hierarchies to distinguish user errors from system errors.
class ConfigError(click.ClickException):
"""Configuration error with user-friendly message."""
def __init__(self, message):
super().__init__(f"Config error: {message}")
@click.command()
@click.option('--config', type=click.Path(exists=True))
def deploy(config):
try:
if not config.endswith('.yaml'):
raise ConfigError("Config must be a YAML file")
click.echo(f"Deploying with {config}...")
except ConfigError as e:
click.echo(str(e), err=True)
raise click.Abort()
@click.command()
@click.option('--input', type=click.Path())
def safe_process(input):
if not click.confirm('Continue processing?'):
click.echo('Aborted.')
ctx = click.get_current_context()
ctx.exit(0)
click.echo(f'Processing {input}...')
if __name__ == '__main__':
deploy()
Frequently Asked Questions
Should I use Click or argparse?
Click requires less boilerplate, produces better help output, and supports nested commands elegantly. Argparse is stdlib (no dependency). For anything beyond a single simple command, I prefer Click.
How do I create interactive prompts?
Use click.prompt() for text input, click.confirm() for yes/no, click.password() for hidden input (passwords). They handle validation and retries automatically.
Can I auto-complete command names in the shell?
Yes, Click supports shell completion. Install the shell completion for your shell (bash, zsh, fish) with _FOO_COMPLETE=source_bash foo > /etc/bash_completion.d/foo.
How do I test a Click CLI?
Click provides CliRunner for testing. It invokes commands, captures output, and asserts exit codes. Use from click.testing import CliRunner and runner.invoke(your_command, ['--option', 'value']).
Originally published on Ayodhyyya. Last updated June 1, 2026.