Input & UX

Shell Completion for Python CLIs

Add tab completion to Python CLIs: how completion works in Click and Typer, generate scripts for bash, zsh, and fish, and offer dynamic suggestions.

Updated

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-completion writes 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.
How tab completion works How tab completion works Shell bash · zsh · fish Your CLI completion mode 1 · press Tab: run CLI in completion mode with current words 2 · CLI prints candidate completions; the shell shows them $ mycli de⇥ deploy describe destroy static choices complete automatically; dynamic values come from your own callback

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.

  1. When your shell starts, it sources a small script that registers a completion function for your command name — for example yourcli.
  2. The user types yourcli deploy --env <Tab>.
  3. 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.
  4. Your program prints one candidate per line (plus a type marker) and exits.
  5. 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.

Completion support across the frameworks A comparison of shell completion support in argparse, Click and Typer, covering built-in support, dynamic values and supported shells. Completion support across the frameworks Capability argparse Click Typer Built in no yes yes Install command third-party eval a script --install-completion Dynamic values third-party shell_complete autocompletion= Shells depends bash, zsh, fish bash, zsh, fish, pwsh This is the clearest practical difference between the stdlib and the frameworks: completion is free in two of the three.

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.

Static and dynamic completions A comparison of completions derived from the command definition versus completions computed by running your code. Static and dynamic completions Kind Comes from Costs Static choices, enums, paths nothing at runtime Dynamic a function you write your code runs on every Tab Dynamic completion runs inside the user's shell prompt — if it takes 300 ms, Tab feels broken.

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's autocompletion=, complete choices and enums automatically, and understand the _YOURCLI_COMPLETE trigger.
  • 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_COMPLETE is set is parsed as a candidate. Route stray output to stderr or guard it.
  • Pin your framework. The shell_complete API landed in Click 8.0 and replaced the older autocompletion argument; Typer's install flags stabilised around 0.12. Pin click>=8.1 / typer>=0.12 and 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.