top of page

Framework vs Flow — Click and Typer for CLI

Writer: Mic
Mic
Jan 16
7 min read

In Part I, we looked at the two extremes: the fully manual wiring of argparse and the almost magical reflection of python-fire. Now we move one step up the abstraction ladder.

If argparse is plumbing and Fire is reflection, then Click and Typer are about designing interfaces. They are not just argument parsers — they are frameworks for building real command-line applications.

One gives you structured, explicit control over commands. The other lets your type hints define the interface almost naturally.

Similar to part I we will look at the two options separately, but how they deal with the same problem and then afterwards go into some more advanced options to show off what other things these libraries can do.

Perhaps ChatGPT is a bit too optimistic how little one has to do for Typer...

Structured Framework: Click

Click is a decorator-based framework for building CLIs. It treats your command-line interface as a proper application: commands, subcommands, options, arguments, help text, context objects.

You don’t just parse arguments — you design commands.

Let’s start simple again with our basic greeting script that we used in the last part.

Running it we get the expected

$ python greet.py
Hello, World!

$ python greet.py --name System
Hello, System!

We also have an immediate help setup

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

Options:
  --name TEXT  Name to greet.
  --help       Show this message and exit.

Notice how the decorators define the CLI explicitly (with some analogy to argparse in how explicit we are). @click.option wires everything together. You control names, defaults, help text — nothing is inferred unless you say so. Click is explicit, but ergonomic.

We can also swtich to our example with positional arguments and simple additions

The setup is very similar, with a @click.command to set up the CLI and then @click.argument to setting up the arguments.

Running this we get

$ python add.py 3 5
3 + 5 = 8

Correspondingly, if you pass something invalid:

$ python add.py 3 hello
Error: Invalid value for 'B': 'hello' is not a valid integer.

Type validation is explicit, we defined it via type=int.

Now we combine the two examples to see command groups

When we run this we can choose what the script should do

$ python add_greet.py greet --name System
Hello, System!

$ python add_greet.py add 4 6
10

Depending on the initial command we have different arguments and options available. Click treats your CLI as an application with multiple commands. This is where it starts to feel like a framework. Note that we never had to give Click the information about the initial commands. These are generated automatically via @click.group and the function defined afterwards.


Modern Abstraction: Typer

Typer is built on top of Click. But instead of defining argument types in decorators, it uses Python type hints.

You write normal Python functions — Typer reads the annotations and builds the CLI. Thus it feels much lighter. Let's start with the basic greeting example again

Running this gives us precisely the same result as with Click

$ python greet.py
Hello, World!

$ python greet.py --name System
Hello, System!

It includes the default of the greet function and also comes predefined with a help output

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

Options:
  --name TEXT  [default: World]
  --help       Show this message and exit.

As we can see it is precisely the one from Click. The important bit here is: we didn’t define an option. We just wrote a function with a default parameter. Typer inferred everything and we had to use the typer.run command to make use of it.


Similarly now if we move to the addition example

and run it we get

$ python add.py 3 5
3 + 5 = 8

Also as with Click we get the same error message if we use wrongly typed arguments

$ python add.py 3 hello
Error: Invalid value for 'B': 'hello' is not a valid integer.

To combine both examples again we have to use the Typer version of command groups which is done very similarly to Click itself by using decorators.

We can see a lot of similarity here, instead of a cli function, we introduce a Typer object and then add this via decorators to the functions we want to be able to trigger. The output of course is the same as in Click

$ python add_greet.py greet --name System
Hello, System!

$ python add_greet.py add 4 6
10

A reminder here, the structure looks similar to Click — because under the hood, it is Click.

We just have to use less decorators to add everything. This is especially easy if you are forcing yourself to use proper type hints everywhere.


Comparison: Framework vs Flow

After looking at both libraries in action, the distinction becomes clearer. Both are powerful. Both can scale from small scripts to large multi-command applications. But they approach the design of a CLI from slightly different angles.

Here is the comparison in a structured way:

Aspect

Click

Typer

Core Philosophy

Explicit CLI framework

Type-driven abstraction

How CLI is Defined

Decorators define options and arguments

Function signatures and type hints define interface

Type Handling

Explicit via type= parameter

Inferred from Python type hints

Boilerplate

Moderate

Low

Learning Curve

Slightly steeper

Gentle if familiar with type hints

Under the Hood

Native framework

Built on Click

Best For

Structured, larger CLI applications

Clean, modern, developer-friendly CLIs

Conceptually, Click treats your CLI as something you construct deliberately and explicitly. You declare options, arguments, groups, and context. It feels like designing an interface layer.

Typer, on the other hand, treats your Python function as the interface. The type hints, defaults, and annotations become the contract. The CLI emerges from your function definition rather than being separately constructed.

The feature sets overlap heavily. The difference lies in how much structure you want to write versus how much you want to infer.


Some More Features

In contrast to part I, we can look at both libraries at the same time here, since there. is so much overlap in features.


Prompts: Asking for Input

Let's say you want an input that is given to the function but not visible in the terminal itself, like a password or an API key. You can easily set up a prompt for this in both Click and Typer. The Click version looks as follows

On running it will ask for a 'Password:' and hide your input, as expected for a password.

The same can be accomplished in Typer via

It is hidden in a more involved default argument. Note the '...' in the code, this is not me be lazy, this signals to Typer that the option is required. Quite similarly one can setup environment variables in both systems.


Context: Global Shared Input

The final feature we want to have a look at is context. By this we means things like global flags, global configuration or database/API connections. We look at Click first and as a first example want to introduce a 'debug' flag

If you run this

$ python context.py run
Running normally

$ python context.py --debug run
Running in debug mode

This is done by Click maintaining a context stack internally. When @click.pass_context is added to a function it tells Click to inject the ctx object into the function. This decorator is responsible to pass and access the context. Thus cli needs it to change the context and run needs it to access the content.

Accomplishing the same in Typer looks very similar again, since they share a common base.

In this case ctx becomes a specific typer.Context object that is globally available to any function. Note that this example now has a main function. At first look this seems redundant, considering that main is not called anywhere. This is fixed with the decorator @app.callback. Any function decorated as such will run before all subcommands. So if we run

$ python context.py run

We first run the function main and then the function run. This is exceptionally useful if you have some logic that would have to be used by any subcommands. Things like establishing a database connection, API access, or setting global flags would all be done in here and is then useable in any subcommand.

In addition, main has a boolean debug that is set via '--debug' for true or '--no-debug' for false. In it this is saved inside a dictionary in the context.

The context is then also provided to the run function directly without any extra need for a decorator. As before the output is completely identical to Click. The difference is the boilerplate. Under the hood they do the exact same thing.


Final Thoughts

Across both parts of this series, we have climbed an abstraction ladder.

argparse forces you to wire everything manually. You define the plumbing yourself, which gives you transparency and control. python-fire removes nearly all ceremony and exposes your Python code directly as a CLI, which is perfect for rapid tooling and experimentation.

Click introduces structure. It encourages you to think about commands, subcommands, shared context, and interface design. It feels like building an application rather than just parsing arguments.

Typer refines that experience. By leaning on type hints, it reduces boilerplate and makes your CLI definitions look almost like ordinary Python functions. It feels modern, expressive, and clean. It combines the features of Click with the inferred approach of python-fire.

None of these libraries is objectively “better.” Each represents a trade-off between control and abstraction, explicitness and convenience. The right choice depends on what you are building, how long it needs to live, and how many people will use it — including future you.

If there is a unifying idea across all four libraries, it is this:

A command-line interface is not just a technical detail. It is an API. And like any API, the way you design it reflects how much structure, clarity, and automation you want in your system.

Choosing your CLI library is really choosing your abstraction level.

Comments


bottom of page