Back to Scaling Python Applications guides

Creating composable CLIs with click in Python

Stanley Ulili
Updated on March 3, 2025

Click is a powerful and flexible Python framework for building command-line interfaces (CLI). It simplifies development with command nesting, parameter validation, and automatic help text generation, making CLI applications more intuitive and maintainable.

This article covers the fundamentals of Click and demonstrates how to build a modular, reusable CLI.

Prerequisites

Before proceeding with the rest of this article, ensure you have a recent version of Python (3.13 or newer) and pip installed locally on your machine. This article assumes you are familiar with basic Python concepts and have some understanding of command-line interfaces.

Getting started with Click

To get the most out of this tutorial, create a new Python project to try out the concepts we will discuss. Start by creating a new directory and setting up a virtual environment:

 
mkdir click-cli && cd click-cli
 
python3 -m venv venv

Activate the virtual environment:

 
source venv/bin/activate

Now, install the latest version of Click:

 
pip install click

Create a new file called cli.py in the root of your project directory, and populate it with the following contents:

cli.py
import click

@click.command()
def hello():
    """Simple program that says hello."""
    click.echo("Hello, world!")

if __name__ == "__main__":
    hello()

The snippet defines a Click command that prints "Hello, world!" to the console. It imports click, decorates a function with @click.command(), and uses click.echo() for output. When run directly, the script executes the hello() function as a CLI command.

Run the script to see the output:

 
python cli.py
Output
Hello, world!

You can also check the automatically generated help text:

 
python cli.py --help
Output
Usage: cli.py [OPTIONS]

  Simple program that says hello.

Options:
  --help  Show this message and exit.

As you can see, Click automatically generates help text based on the function's docstring. This is just one of the many conveniences that Click provides out of the box.

Adding options and arguments to commands

CLI tools are most useful when they can accept user input. Click provides two primary ways to take input: options and arguments.

Options

Options are named parameters that can be specified in any order. They're typically prefixed with one or two hyphens (e.g., -v or --verbose).

Modify your example to include an option:

cli.py
import click

@click.command()
@click.option("--name", "-n", default="World", help="Who to greet")
def hello(name):
"""Simple program that greets NAME."""
click.echo(f"Hello, {name}!")
if __name__ == "__main__": hello()

The updated example introduces the --name option (with the short form -n), which accepts a string value and defaults to "World".

It includes help text in the command's help output, and the provided option value is passed as an argument to the function.

Let's try different ways of invoking this command:

 
python cli.py
Output
Hello, World!
 
python cli.py --name Alice
Output
Hello, Alice!

You can also see the updated help text:

 
python cli.py --help
Output

Usage: cli.py [OPTIONS]

  Simple program that greets NAME.

Options:
  -n, --name TEXT  Who to greet
  --help           Show this message and exit.

Arguments

Unlike options, arguments are positional parameters that must be provided in order. They're typically used for required inputs.

Add an argument to your command:

cli.py
import click

@click.command()
@click.argument("name")
@click.option("--count", "-c", default=1, help="Number of greetings")
def hello(name, count):
"""Simple program that greets NAME for a total of COUNT times."""
for _ in range(count):
click.echo(f"Hello, {name}!")
if __name__ == "__main__": hello()

The updated example introduces a required argument, NAME, which must be provided when running the command. Additionally, the --count (-c) option allows users to specify how many times the greeting should be printed.

Both the argument and option values are passed to the function, which loops accordingly to display the greeting multiple times.

Let's try different ways of invoking this command:

 
python cli.py Alice
Output
Hello, Alice!
 
python cli.py Alice --count 3
Output
Hello, Alice!
Hello, Alice!
Hello, Alice!

Using the --help flag, you can see the updated usage pattern:

 
python cli.py --help
Output
Usage: cli.py [OPTIONS] NAME

  Simple program that greets NAME for a total of COUNT times.

Options:
  -c, --count INTEGER  Number of greetings
  --help               Show this message and exit.

Building command groups for composable CLIs

One of Click's most powerful features is its support for command groups, which allow you to create hierarchical command structures similar to those found in tools like git or docker. This is where Click's composability shines.

Let's create a more complex CLI with multiple commands organized into a group:

cli.py
import click

@click.group()
def cli():
    """Command line tool for managing a simple database."""
    pass

@cli.command()
@click.argument('name')
def create(name):
    """Create a new entry with NAME."""
    click.echo(f"Creating entry: {name}")

@cli.command()
@click.argument('name')
def delete(name):
    """Delete the entry named NAME."""
    click.echo(f"Deleting entry: {name}")

@cli.command()
def list():
    """List all entries."""
    click.echo("Listing all entries")

if __name__ == "__main__":
    cli()

This example defines a command-line tool with multiple commands using @click.group(). The cli() function serves as the main command group, with subcommands added using the @cli.command() decorator.

The create and delete commands accept a required NAME argument, while the list command displays all entries. When the script runs, Click routes user input to the appropriate command based on the provided arguments.

Let's try invoking the different commands:

Create an entry:

 
python cli.py create myentry
Output
Creating entry: myentry

Delete an entry:

 
python cli.py delete myentry
Output
Deleting entry: myentry

List all entries:

 
python cli.py list
Output
Listing all entries

The help system adapts to show both the available commands and the specific help for each command:

 
python cli.py --help
Output
Usage: cli.py [OPTIONS] COMMAND [ARGS]...

  Command line tool for managing a simple database.

Options:
  --help  Show this message and exit.

Commands:
  create  Create a new entry with NAME.
  delete  Delete the entry named NAME.
  list    List all entries.

To get detailed help for a specific command, such as create, use:

 
python cli.py create --help
Output
Usage: cli.py create [OPTIONS] NAME

  Create a new entry with NAME.

Options:
  --help  Show this message and exit.

Nesting command groups

You can further organize your CLI by nesting command groups. This is useful for complex applications with multiple related command sets:

cli.py
import click

@click.group()
def cli():
    """Command line tool for managing a simple database."""
    pass

@cli.group()
def users():
"""Manage users."""
pass
@users.command()
@click.argument('username')
def create(username):
"""Create a new user with USERNAME."""
click.echo(f"Creating user: {username}")
@users.command()
@click.argument('username')
def delete(username):
"""Delete the user with USERNAME."""
click.echo(f"Deleting user: {username}")
@cli.group()
def items():
"""Manage items."""
pass
@items.command()
@click.argument('name')
def create(name):
"""Create a new item with NAME."""
click.echo(f"Creating item: {name}")
@items.command()
@click.argument('name')
def delete(name):
"""Delete the item with NAME."""
click.echo(f"Deleting item: {name}")
if __name__ == "__main__": cli()

In this example, you've organized commands into two subgroups: users and items. Each subgroup has its own create and delete commands.

Let's try invoking some of these nested commands:

 
python cli.py users create alice
Output
Creating user: alice

Delete an item from the database:

 
python cli.py items delete myitem
Output
Deleting item: myitem

The help system accommodates the nested structure:

 
python cli.py --help
Output
Usage: cli.py [OPTIONS] COMMAND [ARGS]...

  Command line tool for managing a simple database.

Options:
  --help  Show this message and exit.

Commands:
  items  Manage items.
  users  Manage users.

To see available commands within a group, use:

 
python cli.py users --help
Output
Usage: cli.py users [OPTIONS] COMMAND [ARGS]...

  Manage users.

Options:
  --help  Show this message and exit.

Commands:
  create  Create a new user with USERNAME.
  delete  Delete the user with USERNAME.

This hierarchical command structure allows you to create complex CLIs that are still intuitive to use and understand.

Parameter types and validation

Click supports various parameter types and validation mechanisms to ensure that user input is correct before it's processed.

Built-in parameter types

Click provides several built-in parameter types, such as str, int, float, bool, and click.File. Here's an example using various parameter types:

cli.py
import click

@click.command()
@click.option('--count', type=int, default=1, help='Number of greetings')
@click.option('--name', prompt='Your name', help='The person to greet')
@click.option('--verbose', is_flag=True, help='Enables verbose mode')
@click.option('--output', type=click.File('w'), default='-', help='Output file')
def hello(count, name, verbose, output):
    """Simple program that greets NAME for a total of COUNT times."""
    for _ in range(count):
        greeting = f"Hello, {name}!"
        if verbose:
            greeting = f"VERBOSE: {greeting}"
        click.echo(greeting, file=output)

if __name__ == "__main__":
    hello()

The --count option, defined as an integer, controls how many times the greeting is printed. If the --name option is not provided, Click prompts the user to enter a name interactively.

The --verbose flag modifies the output by adding a prefix, indicating that verbose mode is active.

Finally, the --output option allows users to specify a file where the greeting should be written, defaulting to standard output if not provided.

Let's see what happens when we provide an invalid value for --count:

 
python cli.py --count abc
Output
Usage: cli.py [OPTIONS]
Try 'cli.py --help' for help.

Error: Invalid value for '--count': 'abc' is not a valid integer.

Click automatically validates the input based on the specified type and provides a helpful error message.

Custom parameter types

You can create custom parameter types for more complex validation needs. Here's an example that validates a date string:

cli.py
import click
from datetime import datetime

class DateType(click.ParamType):
    name = "date"

    def convert(self, value, param, ctx):
        try:
            return datetime.strptime(value, "%Y-%m-%d").date()
        except ValueError:
            self.fail(f"'{value}' is not a valid date in YYYY-MM-DD format", param, ctx)

DATE = DateType()

@click.command()
@click.option('--start-date', type=DATE, help='Start date (YYYY-MM-DD)')
@click.option('--end-date', type=DATE, help='End date (YYYY-MM-DD)')
def report(start_date, end_date):
    """Generate a report for the specified date range."""
    if end_date and start_date > end_date:
        click.echo("Error: End date must be after start date.")
        return
    click.echo(f"Generating report from {start_date} to {end_date}")

if __name__ == "__main__":
    report()

This implementation extends Click’s built-in validation by introducing a custom DateType that ensures dates are properly formatted. If a user enters an invalid date, Click provides a clear error message.

Additionally, the script checks that the end date is not earlier than the start date, further enhancing validation.

A valid input should successfully generate a report:

 
python cli.py --start-date 2024-01-01 --end-date 2024-01-31
Output
Generating report from 2024-01-01 to 2024-01-31

Adding context to your CLI

Click provides a powerful context system that allows you to share state between commands and callbacks. This is especially useful for passing configuration settings or database connections throughout your application.

Using the context object

The context object (ctx) is available to every command and can be used to store and retrieve values:

cli.py
import click

@click.group()
@click.option('--debug/--no-debug', default=False, help='Enable debug mode')
@click.pass_context
def cli(ctx, debug):
    """Command line tool with shared context."""
    # Ensure that ctx.obj exists and is a dict
    ctx.ensure_object(dict)
    ctx.obj['DEBUG'] = debug

@cli.command()
@click.pass_context
def sync(ctx):
    """Synchronize data."""
    debug = ctx.obj['DEBUG']
    if debug:
        click.echo("Debug mode is on")
    click.echo("Syncing data...")

@cli.command()
@click.pass_context
def process(ctx):
    """Process data."""
    debug = ctx.obj['DEBUG']
    if debug:
        click.echo("Debug mode is on")
    click.echo("Processing data...")

if __name__ == "__main__":
    cli(obj={})

In this example, @click.pass_context is used to pass the context object to each command, allowing them to access shared values. The debug flag is stored in the context object, making it available across all commands.

To ensure the context object behaves as expected, ctx.ensure_object(dict) initializes it as a dictionary if it doesn't already exist.

When executing the script, an empty dictionary is passed as the initial context object with cli(obj={}), ensuring proper setup and consistent state management throughout the CLI.

Let's try invoking the commands with and without debug mode:

 
python cli.py sync
Output
Syncing data...

Run the following command with --debug:

 
python cli.py --debug sync
Output
Debug mode is on
Syncing data...

Final thoughts

This article explored Python CLI development with Click, focusing on validation, command nesting, and context sharing. Click’s flexibility and intuitive API make it a powerful choice for building simple and complex CLI applications.

To explore Click’s capabilities, refer to the official documentation.

Thanks for reading and happy coding!

Author's avatar
Article by
Stanley Ulili
Stanley Ulili is a technical educator at Better Stack based in Malawi. He specializes in backend development and has freelanced for platforms like DigitalOcean, LogRocket, and AppSignal. Stanley is passionate about making complex topics accessible to developers.
Got an article suggestion? Let us know
Next article
A Guide to Debugging Python Code with ipdb
Learn how to debug Python applications efficiently with `ipdb`, an enhanced debugger that improves `pdb` with advanced features like better tracebacks, breakpoints, and interactive debugging
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github