How Tab Completion Actually Works in Bash and Zsh
Pressing Tab and getting a sensible list looks simple. Underneath, Bash and Zsh implement a programmable system matching rules specific to each command.
Tab completion feels like a simple, built-in convenience — press Tab, get a sensible suggestion — but underneath, both Bash and Zsh implement it as a genuinely programmable system, where different commands can define entirely different completion rules.
The default behavior: filename completion
Without any specific configuration, both shells fall back to completing filenames and directory names in the current context — typing cd doc and pressing Tab checks the current directory for entries starting with “doc” and completes as far as it can unambiguously.
Where command-specific completion actually comes from
complete -F _git git
Bash’s complete builtin registers a specific completion function for a specific command — _git, in this example, is a function that knows about git’s subcommands, flags, and branch names, and gets invoked automatically whenever you’re completing arguments to git specifically, rather than falling back to generic filename completion.
Where these completion functions actually come from
The bash-completion project provides pre-written completion functions for hundreds of common commands, installed as a package on most Linux distributions and installable via Homebrew on macOS — this is why git checkout <Tab> suggests actual branch names on a properly configured system, rather than just listing files in the current directory.
Zsh’s more structurally rich completion system
Zsh’s completion system (compsys, initialized via compinit) is built around a more structured description format than Bash’s function-based approach — completion definitions can describe argument types, mutual exclusivity between flags, and multi-level subcommand structures more richly, which is part of why Zsh’s completion is often perceived as more capable out of the box.
Why completion sometimes seems to “not know about” a command
complete -p git
# shows the currently registered completion function for git, if any
If Tab completion for a specific command falls back to generic filenames instead of command-aware suggestions, no completion function is currently registered for it — either the relevant completion package isn’t installed, or it hasn’t been sourced in the current shell session yet.
Why completion can behave differently across a script and an interactive shell
Completion configuration is loaded as part of interactive shell startup (from .bashrc or Zsh’s completion initialization), not from the files a non-interactive script reads — this is exactly why completion “works in my terminal” but is irrelevant inside a script, since scripts never trigger interactive Tab-completion behavior at all.
Why understanding this as programmable, not fixed, behavior matters
Once you know a specific completion function is what makes git checkout <Tab> smart about branch names, it stops looking like unexplainable shell magic — you can reasonably ask “does this command have a completion function installed,” troubleshoot why one might be missing, and even understand that writing a custom completion function for your own scripts and tools is genuinely possible, not something reserved for shell internals alone.
Where completion frameworks actually came from
Zsh’s compsys completion framework dates to the mid-1990s, considerably predating widespread completion frameworks for other shells, which is part of why Zsh developed a reputation for the deepest out-of-the-box completion behavior well before Bash’s ecosystem caught up. The bash-completion project, which supplies the completion functions most Linux distributions install by default, began as a later, separate community effort to close that gap for Bash specifically — it is not part of Bash itself, has its own release cycle, and depends on individual maintainers writing and updating a completion function for each covered command as that command’s own interface changes.
Fish takes a fundamentally different approach: generating completions from documentation
Rather than depending on hand-written completion functions per command the way Bash and Zsh primarily do, Fish includes fish_update_completions, a function that scans a system’s installed man pages, strips their formatting, and extracts flags and option descriptions to auto-generate completion definitions — meaning a command with a reasonably well-formatted man page can gain contextual Tab completion in Fish without anyone writing a dedicated completion script for it at all. This does not overwrite a hand-written completion file for the same command if one already exists, so authors can still supply better, more structured completions where the automatic, documentation-derived version falls short.
Generating completion candidates without a full completion function
complete -W "start stop restart status" myservice
For simple, fixed-choice completion, Bash’s complete -W supplies a literal word list directly, without needing a dedicated shell function the way _git-style completions do — useful for a small custom script with a handful of subcommands, where writing and maintaining a full completion function would be disproportionate to the actual complexity being completed.
compgen: the builtin that generates candidates programmatically
compgen is the lower-level builtin that actual completion functions call internally to generate and filter candidate lists — a function registered with complete -F typically calls compgen one or more times internally and stores the result in the COMPREPLY array Bash then displays. Understanding that complete and compgen are two different, cooperating pieces — one registering which function handles a command, the other actually generating candidates inside that function — demystifies what a completion function’s body is usually doing when you read one for the first time.
Writing a minimal completion function is more approachable than it looks
_myservice() {
COMPREPLY=($(compgen -W "start stop restart status" -- "${COMP_WORDS[COMP_CWORD]}"))
}
complete -F _myservice myservice
A working, if basic, completion function for a custom script is genuinely just this: a function that inspects COMP_WORDS (the full command line, split into words) and COMP_CWORD (the index of the word currently being completed), computes candidates, and assigns them to COMPREPLY. The elaborate completion functions bash-completion ships for tools like git or docker are more complex mainly because those tools have far more subcommands, flags, and context-dependent branches to account for — not because the underlying mechanism itself is more complicated than this short example — scale, in other words, not fundamentally different machinery, is almost always what separates a trivial completion function from a genuinely elaborate one.
Related:
- How Shell Prompt Customization Actually Works
- Bash vs. Zsh vs. sh: What Actually Differs Between POSIX Shells
Sources: