Writing a Zsh completion for a custom CLI without slowing your shell
Zsh completions are shell functions registered with compdef and loaded from fpath. Use _arguments for declarative options and cache dynamic lookups to avoid shell lag.
30 Nov 2025, 01:00 UTC

The problem: TAB prints nothing or pauses
You install a small CLI tool, type deploy <TAB>, and either nothing happens or the terminal freezes for a second. Custom Zsh completions fail in two common ways: they are never discovered by the shell, or they run slow external commands on every keystroke. The useful takeaway is that Zsh completion is a shell function registered with compdef, loaded from $fpath, and it must return results almost instantly without printing or changing directory.
How Zsh discovers and runs a completion
Zsh loads completion functions lazily from directories listed in $fpath after compinit runs. A function named _toolname is associated with the command toolname using the #compdef toolname tag inside the file. When you press TAB, Zsh calls that function with words, CURRENT, and PREFIX already set. The function should call compadd or helpers like _arguments and _describe to suggest candidates. It runs in a special widget context, so it must not write to stdout, change directories, or mutate global state.
Where to run: your user shell configuration, typically ~/.zshrc. Required permission: write access to your home directory. Risk: compinit writes ~/.zcompdump; in containers or CI use compinit -C to skip writing.
# ~/.zshrc, before compinit
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit && compinit
Worked example: completion for a deploy CLI
Imagine a CLI named deploy with subcommands build, push, and rollback, plus global flags --env and --dry-run. This completion file lives at ~/.zsh/completions/_deploy.
# ~/.zsh/completions/_deploy
#compdef deploy
_deploy() {
local -a opts subcmds
opts=(
'--env[Target environment]: :(dev staging prod)'
'--dry-run[Simulate without changes]'
)
subcmds=(
'build:Build the artifact'
'push:Push to the registry'
'rollback:Roll back the last release'
)
_arguments -C -s ${opts[@]} '*::subcommand:_deploy_subcmds'
}
_deploy_subcmds() {
_describe -t commands 'deploy subcommand' subcmds
}
_deploy
Expected check: restart the shell or run exec zsh, then type deploy <TAB>. The menu should list build, push, and rollback with their descriptions. Type deploy --env <TAB> and dev, staging, prod should appear. The *::subcommand:_deploy_subcmds spec tells Zsh to treat the next word as a subcommand and invoke the helper.
Keep dynamic completions fast with caching
If your completion calls an external command, such as querying an API or listing remote resources, run it once and cache the result. A slow completion blocks the prompt because the shell waits for the function to return before showing candidates.
Where to run: inside the completion function or a wrapper script. Required permission: write to a cache directory such as ~/.cache/zsh-completions. Risk: stale cache data can suggest options that no longer exist; use a short TTL.
# Inside a completion function, illustrative pattern
local cache_file="$HOME/.cache/zsh-completions/deploy-envs"
local now=$(date +%s)
if [[ ! -f "$cache_file" ]] || (( now - $(stat -c %Y "$cache_file") > 300 )); then
deploy list-environments --format=json > "$cache_file"
fi
local -a envs
envs=(${(f)"$(cat "$cache_file")"})
_describe -t environments 'environment' envs
Practical check: time the completion in a clean shell with zsh -ic '_deploy; compadd -Q --'. It should complete well under 100 ms. If it is slower, add caching or fall back to static lists.
Limits and rollback
Zsh completion is not POSIX and does not understand bash-completion scripts directly. Large candidate lists can overflow the terminal display; use zstyle ':completion:*' list-packed or filter by prefix inside the function. Because this change writes files to your home directory, rollback means removing ~/.zsh/completions/_deploy, deleting ~/.zcompdump, and restarting the shell.
Actionable close
Create ~/.zsh/completions, add it to fpath before compinit, and write a _cmd function with #compdef cmd. Use _arguments for flags and subcommands, keep the function free of side effects, and cache any external lookups. Verify with exec zsh and TAB, then measure latency if you call external commands.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.