pyguides

Building CLIs with Click

Click is a Python library for building command-line interfaces. It handles the boring parts — option parsing, help generation, error handling — so you can focus on what your tool actually does. You write decorators, Click handles the wiring.

Installation

pip install click

Click requires Python 3.7 or later.

Your First Command

import click

@click.command()
@click.option('--count', default=1, help='How many times to greet')
@click.option('--name', default='World', help='Who to greet')
def hello(count, name):
    """Print a greeting COUNT times."""
    for _ in range(count):
        click.echo(f'Hello, {name}!')

if __name__ == '__main__':
    hello()

Run it:

$ python hello.py --count=3 --name Alice
Hello, Alice!
Hello, Alice!
Hello, Alice!

The --help flag works automatically:

$ python hello.py --help
Usage: hello.py [OPTIONS]

  Print a greeting COUNT times.

Options:
  --count INTEGER  How many times to greet
  --name TEXT      Who to greet
  --help           Show this message and exit

Options vs Arguments

Options start with -- (or - for short form) and are optional by default. Arguments are positional and required by default.

@click.command()
@click.option('--verbose', '-v', is_flag=True)       # --verbose or -v
@click.argument('filename')                          # positional, required
def process(verbose, filename):
    if verbose:
        click.echo(f'Processing {filename}')

Arguments are for things the user must provide. Options are for modifying behavior.

Common Option Patterns

Boolean Flags

@click.option('--verbose', '-v', is_flag=True)
@click.option('--quiet', '-q', is_flag=True)

When present on the command line, the value is True. Otherwise it’s False.

Options with Defaults

@click.option('--port', default=8080, type=int)
@click.option('--host', default='localhost')

The user can override: --port 9000.

Choice Constraints

@click.option('--format', type=click.Choice(['json', 'yaml', 'csv']))

Click validates and shows valid choices in the error message.

Counting (Multiple Flags)

@click.option('-v', '--verbose', count=True)

Each -v on the command line increments the count:

$ python app.py -vvv
# verbose = 3

Password Prompt

@click.option('--password', prompt=True, hide_input=True)

Click prompts the user interactively if --password isn’t provided on the command line.

Arguments

Arguments are positional and generally required:

@click.command()
@click.argument('input_file')
@click.argument('output_file')
def convert(input_file, output_file):
    """Convert INPUT_FILE to OUTPUT_FILE."""
    click.echo(f'Converting {input_file} to {output_file}')
$ python convert.py data.csv result.json
# input_file = 'data.csv'
# output_file = 'result.json'

Use nargs=N for a fixed number of arguments, or nargs=-1 for variadic:

@click.argument('files', nargs=-1)
def process_multiple(files):
    # files is a tuple
    for f in files:
        process(f)
$ python script.py file1.txt file2.txt file3.txt
# files = ('file1.txt', 'file2.txt', 'file3.txt')

Validation

Click doesn’t have built-in validation, but you can add it in the function body:

@click.command()
@click.option('--age', type=int)
def check_age(age):
    if age is not None and age < 0:
        raise click.ClickException('Age cannot be negative')
    click.echo(f'Age: {age}')

For argument validation, use click.ParamType:

class AgeType(click.ParamType):
    name = 'AGE'

    def convert(self, value, param, ctx):
        if not isinstance(value, str) or not value.isdigit():
            self.fail(f'{value!r} is not a valid age', param, ctx)
        age = int(value)
        if age > 150:
            self.fail(f'{age} is unreasonably old', param, ctx)
        return age

@click.command()
@click.argument('age', type=AgeType())
def person(age):
    click.echo(f'Age: {age}')

Prompts for Interactive Input

@click.command()
@click.option('--name', prompt=True)
@click.option('--confirm', prompt=True, confirmation_prompt=True)
def setup(name, confirm):
    click.echo(f'Name: {name}, Confirmed: {confirm}')

If --name isn’t provided, Click pauses and asks. The confirmation_prompt=True asks twice and checks they match.

Multi-Command CLIs

Click supports nested command groups. This is useful for tools like git:

@click.group()
def cli():
    """Main entry point."""
    pass

@cli.command()
@click.option('--verbose', '-v', is_flag=True)
def build(verbose):
    """Build the project."""
    click.echo('Building...' if verbose else 'Done')

@cli.command()
def test():
    """Run tests."""
    click.echo('Running tests...')

if __name__ == '__main__':
    cli()
$ python cli.py build --verbose
Building...
$ python cli.py test
Running tests...
$ python cli.py --help
Usage: cli.py [OPTIONS] COMMAND [ARGS]...

Using add_command

You can also build groups programmatically:

@click.command('deploy')
def deploy():
    click.echo('Deploying...')

@click.command('rollback')
def rollback():
    click.echo('Rolling back...')

cli = click.Group()
cli.add_command(deploy)
cli.add_command(rollback)

Command Chains

By default, a Click group executes exactly one subcommand. Use invoke_without_command=True to allow the group itself to run:

@click.group(invoke_without_command=True)
@click.option('--version', is_flag=True)
def cli(version):
    if version:
        click.echo('mytool v1.0.0')
    elif click.invoked_subcommand is None:
        click.echo('Run --help for usage')

@cli.command()
def start():
    click.echo('Starting...')

Reading Files

Click handles file paths elegantly with click.File:

@click.command()
@click.argument('input', type=click.File('r'))
@click.argument('output', type=click.File('w'))
def transform(input, output):
    """Transform INPUT to OUTPUT."""
    content = input.read()
    output.write(content.upper())
$ python transform.py input.txt output.txt
# Or with stdin/stdout:
$ cat input.txt | python transform.py - output.txt

Use 'rb'/'wb' for binary mode. Click also handles Path-like objects automatically.

Styling Output

@click.command()
def status():
    click.secho('Success!', fg='green')
    click.secho('Warning:', fg='yellow', err=True)
    click.echo(click.style('Bold text', bold=True))

Colors work in terminals that support them. The err=True option sends output to stderr.

Progress Bars

import time

@click.command()
def download():
    with click.progressbar(length=100, label='Downloading') as bar:
        for i in range(100):
            time.sleep(0.05)
            bar.update(1)

Testing Click Commands

Click ships with a testing utilities module:

from click.testing import CliRunner

def test_hello():
    runner = CliRunner()
    result = runner.invoke(hello, ['--name', 'Alice', '--count', '2'])
    assert result.exit_code == 0
    assert 'Hello, Alice!' in result.output

runner.invoke() runs the command and returns a Result object with output, exit_code, and exception. The runner can also simulate file inputs:

def test_transform():
    runner = CliRunner()
    with runner.isolated_filesystem():
        with open('input.txt', 'w') as f:
            f.write('hello')
        result = runner.invoke(transform, ['input.txt', 'output.txt'])
        assert result.exit_code == 0
        assert open('output.txt').read() == 'HELLO'

Common Pitfalls

Options Are Keyword-Only

Click options become function parameters. Don’t use positional args in your function:

# Wrong
@click.command()
@click.option('--name')
def hello(name, extra):  # extra isn't a parameter
    pass

# Right
@click.command()
@click.option('--name')
def hello(name):
    pass

@click.group() vs @click.command()

Decorators chain. A group uses @click.group() then @group.command() for subcommands. A standalone command uses @click.command(). Mixing them up causes cryptic errors.

Mixing Options and Arguments

Put all @click.argument decorators before @click.option decorators in your function. Click reorders them for parsing, but the decorator order in the function signature matters for readability:

# Arguments first, then options
@click.argument('input')
@click.option('--verbose')
def process(input, verbose):
    pass

Exit Codes on Exceptions

Click catches exceptions and shows error messages, but the exit code is 1 by default. To control exit codes, use click.Abort() or raise SystemExit with a specific code:

@click.command()
def fail():
    click.secho('Fatal error', fg='red', err=True)
    raise SystemExit(1)

See Also