Tab completion is the fastest quality-of-life upgrade you can ship for a command-line tool: press Tab and the shell fills in your subcommands, options, and even valid values for an argument. Done well it also teaches the interface — users discover commands without ever opening the docs. This overview explains how completion actually works, what Click and Typer give you out of the box, and how the two deeper guides fit together so you can turn it on and install it on every shell your users run.
TL;DR
- Completion is a handshake: when the user presses Tab, the shell re-invokes your program in a special completion mode, your program prints candidate strings, and the shell displays them.
- Typer has it built in —
--install-completionwrites the shell script for you. Click 8 has the same engine underneath but you wire the install step yourself. - Completions can be static (the fixed set of subcommands and choices, computed for free from your command tree) or dynamic (values pulled at Tab-time from a file, an API, or the current arguments).
- Two moving parts: enabling completion in your Python code, and installing the generated script into bash, zsh, or fish. They fail independently, so this section splits them into two guides.
What completion is and why it matters
When you type git com and press Tab and it becomes git commit, the shell did not read a static list of git's subcommands from a config file. It ran a small piece of shell code — a completion function — that git registered when your shell started. For a Python CLI the goal is the same: register a completion function that knows your commands, options, and arguments, and keep it in sync with your code automatically instead of by hand.
The payoff is concrete. Users stop mistyping subcommands, stop guessing flag names, and stop grepping --help for the option that takes an environment name. For a tool with dynamic inputs — deploy targets, dataset IDs, profile names — completion can suggest the actual valid values, which is the difference between a CLI that feels alive and one that feels like a form you have to fill out perfectly on the first try.
Completion also composes with the rest of a good CLI. It works best on a clean command tree (see structuring multi-command Python CLIs) and it pairs naturally with a polished interactive terminal UI: completion helps users assemble the command, Rich makes the result readable.
The completion handshake
Here is the mechanism every framework builds on. Nothing about it is Python-specific.
- When your shell starts, it sources a small script that registers a completion function for your command name — for example
yourcli. - The user types
yourcli deploy --env <Tab>. - The registered function re-runs your program with an environment variable set (Click and Typer use
_YOURCLI_COMPLETE) and the current words passed in. This is completion mode: your program does not do its real work, it prints candidates. - Your program prints one candidate per line (plus a type marker) and exits.
- The shell reads that list and either fills in the single match or shows the menu.
You can watch the handshake happen by hand, which is the single most useful debugging trick in this whole area:
$ _YOURCLI_COMPLETE=bash_complete COMP_WORDS="yourcli dep" COMP_CWORD=1 yourcli
plain,deploy
plain,describe
That is Click's completion protocol running directly — no shell involved. If this prints your candidates, your Python side works and any problem is in the install step. If it prints nothing, the bug is in your code. Keeping those two failure modes separate is exactly why this section is split into an enabling guide and an installing guide.
Click vs Typer support at a glance
Both frameworks share the same completion engine — Typer is built on Click — so the underlying protocol is identical. What differs is how much is handed to you.
Typer wires up an install command for free. Every Typer app automatically gains two hidden options:
# app.py
import typer
app = typer.Typer()
@app.command()
def deploy(env: str, replicas: int = 1) -> None:
"""Deploy the service."""
typer.echo(f"Deploying to {env} with {replicas} replicas")
if __name__ == "__main__":
app()
$ python app.py --install-completion # detects your shell and installs
$ python app.py --show-completion # prints the script to stdout instead
Click has the same machinery but leaves the install step to you — you tell users to eval or source a generated script:
# cli.py
import click
@click.group()
def cli() -> None:
"""Example tool."""
@cli.command()
@click.option("--env", required=True)
def deploy(env: str) -> None:
"""Deploy the service."""
click.echo(f"Deploying to {env}")
if __name__ == "__main__":
cli()
$ eval "$(_CLI_COMPLETE=bash_source cli)" # activate for the current shell
The trade-off between these two frameworks — convenience versus control — is the same one that runs through Typer vs Click: when to use each. For completion specifically, Typer saves you writing an install command, and Click gives you a script you can drop into a package's post-install step.
Static vs dynamic completions
Static completions are the ones your framework can compute from the command tree with no help: the names of subcommands, the option flags, and any click.Choice / Enum values. You get these the moment completion is enabled — they cost nothing.
Dynamic completions are computed at Tab-time by a callback you write. Use them when the valid values live outside your source code:
import click
def complete_env(ctx, param, incomplete):
# In real code, read from a config file or API.
known = ["staging", "prod-eu", "prod-us"]
return [e for e in known if e.startswith(incomplete)]
@click.command()
@click.option("--env", shell_complete=complete_env)
def deploy(env: str) -> None:
click.echo(f"Deploying to {env}")
Now yourcli deploy --env pro<Tab> offers prod-eu and prod-us. The callback receives the partial word (incomplete) so you can filter server-side and keep the list short. Two rules keep dynamic completion pleasant: make the callback fast (it runs on every keypress-plus-Tab, so cache or bound any network call), and make it safe to fail (return an empty list rather than raising, so a broken suggestion never blocks the user's shell). The enabling guide covers the equivalent autocompletion hook in Typer and how to return richer CompletionItem values with help text.
The two guides in this section
Completion has two independent jobs, and this section gives each its own guide:
- Enabling tab completion in Click and Typer — the Python side. Turn completion on, add dynamic completions for arguments and options with Click's
shell_complete=and Typer'sautocompletion=, complete choices and enums automatically, and understand the_YOURCLI_COMPLETEtrigger. - Installing shell completion for bash, zsh, fish — the shell side. Generate the completion script per shell, put it where each shell will load it, and troubleshoot completions that never fire (rehash, a fresh shell,
PATH, and caching).
Read them in that order: get the handshake working in Python first (verify with the _YOURCLI_COMPLETE trick above), then install the script so it runs automatically.
Production notes
- Completion runs your import path on every Tab. A slow startup makes completion feel laggy, so keep top-level imports light — the same discipline as CLI startup performance and lazy loading.
- Never print to stdout during completion mode. Anything your program writes to stdout while
_YOURCLI_COMPLETEis set is parsed as a candidate. Route stray output to stderr or guard it. - Pin your framework. The
shell_completeAPI landed in Click 8.0 and replaced the olderautocompletionargument; Typer's install flags stabilised around 0.12. Pinclick>=8.1/typer>=0.12and test against the version you ship. - The installed name matters. The completion script keys off your console-script name, so it must match your entry point. Rename the command and you must regenerate the script.
Related
- Enabling tab completion in Click and Typer — the Python side, including dynamic completions.
- Installing shell completion for bash, zsh, fish — the per-shell install and troubleshooting.
- Interactive terminal UI with Rich — the other half of a great CLI experience.
- Typer vs Click: when to use each — how the two frameworks differ, completion included.
- Advanced Input Parsing for Python CLIs — the parent track on input and UX.