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.
Turning completion on, per framework
The mechanics differ in spelling more than substance.
Typer ships the installer:
mytool --install-completion # writes the script and the profile line
mytool --show-completion # prints it instead, if you would rather install it yourself
The generated script is written into the user's shell configuration directory and a line is appended to their profile. The step people miss is that a new shell is required — the profile only runs at start-up, so nothing happens in the terminal where the command was typed.
Click expects you to install it, which is a one-line instruction in your README:
# bash — in ~/.bashrc
eval "$(_MYTOOL_COMPLETE=bash_source mytool)"
# zsh — in ~/.zshrc
eval "$(_MYTOOL_COMPLETE=zsh_source mytool)"
# fish — in ~/.config/fish/completions/mytool.fish
_MYTOOL_COMPLETE=fish_source mytool | source
The variable name is derived from your command name, uppercased with hyphens replaced by
underscores. Note that eval runs your program on every shell start-up, which for a slow-starting
CLI is a real cost — generating the script into a file once is the better arrangement:
_MYTOOL_COMPLETE=bash_source mytool > ~/.local/share/bash-completion/completions/mytool
argparse has no built-in support. argcomplete fills the gap with a decorator and a
registration step, at the cost of the dependency you were probably avoiding by choosing argparse
in the first place.
Dynamic completion that stays fast
Static completion — command names, enum values, Path types — is free and costs nothing at run
time. Dynamic completion runs your code inside the user's shell prompt, which changes the
constraints entirely.
def complete_environment(incomplete: str) -> list[str]:
"""Called on every Tab. Must be fast and must never print to stdout."""
known = cached_environment_names() # read a local cache, not the network
return [name for name in known if name.startswith(incomplete)]
@app.command()
def deploy(
env: Annotated[str, typer.Option(autocompletion=complete_environment)],
) -> None:
...
The Click spelling is shell_complete=, with a callback that receives the context, the parameter
and the incomplete string, and returns CompletionItem objects — which can carry a help string
that zsh and fish will display beside each candidate.
Three rules make the difference between completion that people use and completion they turn off:
Budget about 100 ms, total. That includes your interpreter start-up, which for a CLI with heavy imports may already exceed it. This is the single strongest practical argument for keeping startup fast.
Never touch the network. A completion callback that makes an HTTP request makes Tab feel broken on a flaky connection and hangs the prompt on a bad one. Read from a cache the tool refreshes during normal commands, and accept that it may be slightly stale.
Never print. Anything your callback writes to stdout is interpreted as completion candidates. A stray debug print produces garbage suggestions that are extremely confusing to diagnose.
Testing completion without a shell
An interactive shell is not reproducible in CI, so drive the completion machinery directly. Both frameworks respond to the same environment protocol they use in production:
import subprocess, sys
def complete(words: str, cword: int) -> list[str]:
result = subprocess.run(
[sys.executable, "-m", "mytool"],
env={
**os.environ,
"_MYTOOL_COMPLETE": "bash_complete",
"COMP_WORDS": words,
"COMP_CWORD": str(cword),
},
capture_output=True, text=True,
)
return [line.split(",", 1)[1] for line in result.stdout.splitlines() if "," in line]
def test_environment_completion_filters_by_prefix():
assert complete("mytool deploy --env pro", 3) == ["prod", "prod-eu"]
That is the whole test surface: given a partial command line, the program returns the right candidates. It runs anywhere, it is fast, and it fails when someone breaks the callback — which is otherwise a regression nobody notices until a user mentions that Tab stopped working.
Caching the data completion needs
The tension in dynamic completion is that the useful candidates — environment names, remote branches, table names — usually live somewhere slow. The resolution is to never fetch them during completion at all.
from pathlib import Path
import json, time
CACHE = Path.home() / ".cache" / "mytool" / "environments.json"
MAX_AGE = 24 * 3600
def cached_environment_names() -> list[str]:
"""Read-only, never refreshes. Returns [] rather than blocking or failing."""
try:
payload = json.loads(CACHE.read_text())
except (OSError, ValueError):
return []
return payload.get("names", [])
def refresh_environment_cache(names: list[str]) -> None:
"""Called by ordinary commands, which are allowed to be slow."""
CACHE.parent.mkdir(parents=True, exist_ok=True)
CACHE.write_text(json.dumps({"names": names, "at": time.time()}))
Every normal command that already knows the answer — mytool env list, a deploy that resolved an
environment — writes the cache as a side effect. Completion only ever reads it, and returns an
empty list when the file is missing or unreadable rather than raising.
The trade is that suggestions can be stale, which is almost always acceptable: a missing candidate
means the user types the name in full, while a two-second Tab means they stop pressing Tab at all.
If staleness matters, add a visible refresh command and mention it in --help, so the behaviour is
something users can reason about rather than a mystery.
Frequently asked questions
Why does completion work in one terminal and not another?
Almost always because the profile line has not been loaded in that shell. Completion is installed
by writing a script and adding a line to a start-up file, so only shells started afterwards see
it. The second most common cause is zsh's compinit running before the directory containing your
script was added to $fpath.
Should I ship the completion script in the package?
Only if you also tell users where to put it. A generated script inside the wheel does nothing on
its own; the useful thing is a documented command — mytool --show-completion > … — or a
post-install step in your Homebrew or distribution package, where placing the file is expected.
Does completion slow down my shell start-up?
The eval form does, because it runs your program every time a shell opens. Writing the generated
script to a file once removes that cost entirely, and it is the arrangement worth documenting for
anyone whose CLI takes more than a few tens of milliseconds to start.
Can I complete file paths as well as choices?
Yes, and you get it free by using the right type. A Path parameter tells the shell to use its
own filename completion, which is faster and better behaved than anything you would write. Only
reach for a custom callback when the candidates are not files.
How do I complete values that depend on another flag?
Click's callback receives the context, so ctx.params gives you the values parsed so far — enough
to complete --table based on the --database already typed. Typer's callback can take a
typer.Context for the same reason. Be careful about ordering: the user may not have typed the
other flag yet, so always handle the missing case rather than raising.
Is completion worth the effort for an internal tool?
It is arguably worth more there. Internal tools have fewer users but those users run them dozens of times a day, and the values they type — environment names, service identifiers, ticket references — are exactly the kind that nobody remembers exactly. Static completion of subcommands and enums costs nothing beyond choosing the right parameter types, so the question is really only about the dynamic parts — and those are usually the values your team argues about in chat, which is a good sign they belong behind Tab.
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.