Zsh Scripting: Completion System Complete Guide
The Zsh completion system (often referred to as compinit and the _* functions) is one of the most powerful features that sets Zsh apart from other shells. Unlike Bash's bash-completion, Zsh ships with a first-class, programmable completion engine capable of context-aware tab completion, menu selection, descriptions, grouping, and dynamic candidate generation. This guide walks you through everything from enabling the system to writing your own completion functions for custom commands.
What Is the Zsh Completion System?
The Zsh completion system is a framework built into Zsh that provides intelligent, context-sensitive tab completion for commands, options, arguments, files, directories, and even dynamic values fetched from external sources. It is composed of three main pieces:
- The
compinitautoloaded function — initializes the completion system and loads all completion definitions. - The
_*completion functions — individual functions (e.g.,_git,_docker) that define how a specific command should be completed. - The
compsysutility functions — helpers like_arguments,_values,_describe, and_filesthat make writing completions declarative.
When you press Tab, Zsh inspects the current command line, identifies the active command and cursor position, then dispatches to the matching completion function which produces a list of candidates. The candidates can include descriptions, be grouped by category, and even be filtered as you type.
Why It Matters
For developers and power users, a robust completion system is a productivity multiplier. Here's why mastering it pays off:
- Fewer errors — completing valid options prevents typos and invalid flag combinations.
- Discoverability — inline descriptions expose options you didn't know existed.
- Speed — context-aware completion of subcommands, branches, containers, or hosts eliminates context switching to man pages.
- Polish for your own tools — distributing a completion function alongside a CLI tool dramatically improves the user experience.
Enabling the Completion System
Before writing completions, you must enable the system in your ~/.zshrc. The standard incantation is:
# Load the completion system
autoload -Uz compinit
compinit
# Useful completion styling
zstyle ':completion:*' menu select
zstyle ':completion:*' matcher-list 'm:{a-zA-Z}={A-Za-z}'
zstyle ':completion:*' group-name ''
zstyle ':completion:*:descriptions' format '%F{yellow}-- %d --%f'
zstyle ':completion:*:warnings' format '%F{red}-- no matches --%f'
Let's break down what each zstyle line does:
menu select— enables arrow-key navigation through completion candidates.matcher-list— case-insensitive completion (lowercase matches uppercase and vice versa).group-name ''— groups completions by their description tag.descriptions— colors group headers yellow.warnings— colors "no matches" messages red.
For faster startup on systems with many completion files, you can use compinit -C to skip the security check, or cache the check with a daily timestamp:
autoload -Uz compinit
if [[ -n ${ZDOTDIR}/.zcompdump(#qN.mh+24) ]]; then
compinit
else
compinit -C
fi
How Completion Functions Are Discovered
Zsh looks for completion functions in the directories listed in $fpath. Each function must be named with a leading underscore matching the command it completes. For example, to complete a command mycli, you create a file named _mycli in one of your fpath directories.
To add a personal completions directory:
# In ~/.zshrc, BEFORE compinit
fpath+=~/.zsh/completions
autoload -Uz compinit
compinit
Then place ~/.zsh/completions/_mycli and Zsh will automatically use it for the mycli command.
Anatomy of a Completion Function
Every completion function follows a predictable structure. Here is the minimal skeleton:
#compdef mycli
_mycli() {
local context state line
typeset -A opt_args
_arguments -C \
'(-h --help)'{-h,--help}'[show help]' \
'(-v --verbose)'{-v,--verbose}'[enable verbose output]' \
'1: :_mycli_subcommands' \
'*::arg:->args'
}
_mycli_subcommands() {
local -a subs
subs=(
'init:initialize a new project'
'build:build the project'
'deploy:deploy the project'
)
_describe 'subcommand' subs
}
_mycli "$@"
The first line #compdef mycli is a magic comment that tells compinit which command this function completes. The _arguments helper is the workhorse — it parses the command line declaratively and dispatches to other helpers when needed.
Using _arguments Effectively
The _arguments function accepts a list of spec strings. Each spec describes one option or positional argument. The general syntax is:
'(-x --exclude)'{-x,--exclude}'[exclude pattern]:pattern:_files'
Breaking this apart:
(-x --exclude)— mutually exclusive options; specifying one suppresses the other.{-x,--exclude}— both short and long forms share the same spec.[exclude pattern]— the description shown in the menu.:pattern:— an argument label (used internally)._files— the completion function used to fill the argument; here, file paths.
Here is a richer example for a hypothetical imgtool command:
#compdef imgtool
_imgtool() {
local context state line
typeset -A opt_args
_arguments -C \
'(- *)'{-h,--help}'[print help]' \
'(-q --quiet)'{-q,--quiet}'[suppress output]' \
'--format=[output format]:format:(json yaml text)' \
'--quality=[jpeg quality 0-100]:quality: ' \
'1:command:(resize convert optimize info)' \
'*::args:->args'
case $state in
args)
case ${line[1]} in
resize)
_arguments \
'1:source image:_files -g "*.(jpg|jpeg|png|webp)"' \
'2:target image:_files'
;;
convert)
_arguments \
'1:source:_files' \
'2:target:_files' \
'--to=[target format]:format:(png jpg webp avif)'
;;
optimize)
_values 'images' \
'*:image:_files -g "*.(jpg|jpeg|png|webp)"'
;;
info)
_files -g "*.(jpg|jpeg|png|webp|gif|tiff)"
;;
esac
;;
esac
}
_imgtool "$@"
Notice how the *::args:->args spec captures everything after the subcommand and routes it through the state machine, letting you provide subcommand-specific completion.
Describing Values with _describe and _values
_describe is perfect for static lists with descriptions. It takes a tag name and an array of name:description entries:
local -a envs
envs=(
'dev:development environment'
'staging:pre-production environment'
'prod:production environment'
)
_describe 'environment' envs
_values is similar but supports multiple selections and per-value arguments:
_values -s , 'features' \
'auth[enable authentication]' \
'cache[enable caching]:size:(small large)' \
'metrics[enable metrics]'
The -s , flag tells Zsh the values are comma-separated, so users can complete --features auth,cache,metrics incrementally.
Dynamic Completions from External Sources
One of the most powerful patterns is generating candidates at completion time. For example, completing Git branch names dynamically:
#compdef gco
_gco() {
local branches
branches=(${(f)"$(git branch --all --format='%(refname:short)' 2>/dev/null)"})
_describe 'branch' branches
}
_gco "$@"
The ${(f)...} parameter expansion flag splits the command output on newlines into an array. This pattern works for Docker containers, Kubernetes pods, SSH hosts, Makefile targets — anything you can script.
Here's a more elaborate example completing Makefile targets:
#compdef make
_make() {
local -a targets
if [[ -f Makefile ]]; then
targets=(${(f)"$(awk '/^[a-zA-Z0-9_-]+:/ {sub(/:.*/,""); print}' Makefile 2>/dev/null)"})
_describe 'make target' targets
fi
}
_make "$@"
Completing Files with Filters
The built-in _files helper accepts glob patterns via -g to restrict which files are offered:
# only Python files
_files -g '*.py'
# only directories
_files -/
# only images, with a custom description
_files -g '*.(png|jpg|jpeg|webp|gif)' -X 'image files'
Grouping and Tagging Completions
For commands that accept multiple kinds of arguments, you can group candidates so the menu is organized. Use _wanted, _requested, and _tags to label groups:
_mycli() {
_tags files hosts
while _tags; do
_requested files expl 'config file' _files -g '*.conf'
_requested hosts expl 'remote host' _hosts
done
}
Each _requested block produces a labeled group in the completion menu, and Zsh will display them under their respective headers.
Handling Subcommands Like Git
Many modern CLIs use a nested subcommand structure. The idiomatic Zsh approach is to dispatch to a separate function per subcommand. Here's a compact pattern:
#compdef mycli
_mycli() {
local context state line
typeset -A opt_args
_arguments -C \
'(- *)'{-h,--help}'[show help]' \
'--version[show version]' \
'1: :->cmd' \
'*::arg:->args'
case $state in
cmd)
_values 'subcommand' \
'init[initialize]' \
'run[run a task]' \
'config[manage config]' \
'login[authenticate]'
;;
args)
if (( $+functions[_mycli_${line[1]}] )); then
_mycli_${line[1]}
fi
;;
esac
}
_mycli_run() {
_arguments \
'(-d --detach)'{-d,--detach}'[run in background]' \
'1:task:_files -g "*.task"'
}
_mycli_config() {
_arguments \
'1:action:(get set list delete)' \
'2:key: '
}
_mycli "$@"
This pattern scales well: each subcommand gets its own function, and the dispatcher routes to it dynamically. Adding a new subcommand is just a matter of defining _mycli_newsub.
Testing and Debugging Completions
While developing, you can force Zsh to reload your completion without restarting the shell:
unfunction _mycli 2>/dev/null
autoload -Uz _mycli
To inspect what completions are being generated, use compadd tracing by setting:
functions _mycli | less # view the loaded function
zstyle ':completion:*' verbose yes
zstyle ':completion:*' list-separator '----'
You can also test the candidate list directly by calling the function in a controlled way:
# Inside a test script
source ~/.zsh/completions/_mycli
words=(mycli resize)
CURRENT=3
_mycli
Best Practices
- Name files correctly — the file must be
_commandnameand live in a directory on$fpath. - Always end with the dispatch call — every completion file should end with
_commandname "$@"so it actually runs. - Use
-Con_argumentswhen you need state-based dispatch; it sets$stateand$linefor you. - Provide descriptions — they turn completion from a typing aid into a discovery tool.
- Guard external commands — wrap dynamic generators in
2>/dev/nulland check for the existence of the underlying tool before calling it. - Keep completions fast — completion runs synchronously on every Tab press; avoid slow network calls or cache their results.
- Test edge cases — empty arguments, partial flags, and options after positional arguments all behave differently.
- Reuse built-in helpers — prefer
_files,_dirs,_hosts,_users,_man, and_gitover rolling your own. - Document mutual exclusions — use the
(-a --alt)syntax so Zsh suppresses conflicting options automatically. - Distribute with your tool — ship the
_*file in your package and instruct users to add it tofpath, or install it to a standard location like/usr/local/share/zsh/site-functions.
Common Pitfalls
- Forgetting
compinit— without it, no custom completions load at all. - Adding to
fpathaftercompinit— thefpathmodification must happen beforecompinitruns. - Using
localwithouttypeset -A opt_args—_arguments -Cpopulates$opt_args, which must be declared as an associative array. - Returning candidates with spaces — quote array expansions properly, or use
${(f)...}for line-based output. - Not handling the
argsstate — if you use*::arg:->args, you must handle theargsstate or subcommand completion silently does nothing.
Conclusion
The Zsh completion system is a declarative, composable framework that turns the humble Tab key into a powerful interface for navigating commands, options, and dynamic data. By understanding compinit, mastering _arguments, and leveraging helpers like _describe, _values, and _files, you can write completion functions that rival those shipped for Git, Docker, and Kubernetes. Start small with a single custom command, iterate using the reload-and-test workflow, and apply the best practices above to keep your completions fast, descriptive, and robust. Once you've shipped a polished completion alongside your own CLI tool, you'll understand why Zsh users consider the completion system one of the shell's defining features.