Fuzzy search instead of rigid numbered menus
fzf turns any list a Bash script produces into a searchable selection menu. Instead of typing numbers, the user types a few letters and instantly gets a filtered list of matches. This article shows how to integrate fzf into your own scripts, including preview windows, multi-select and a solid fallback for environments without fzf.
Table of Contents
- 1. Why fzf in Bash scripts deserves its own pattern
- 2. Core principle: fzf as a filter between two pipes
- 3. First integration: capturing the selection in a variable
- 4. Preview window: showing context for the selection
- 5. Multi-select with -m and processing the results
- 6. Practical example: Git branch and container switcher
- 7. Adjusting key bindings and layout
- 8. Fallback: staying robust without fzf
- 9. fzf compared to select, read and whiptail
- 10. Summary
- 11. FAQ
1. Why fzf in Bash scripts deserves its own pattern
fzf is a command line fuzzy finder that reads a list of lines from stdin and filters it interactively by substring as the user types. On its own that is already useful, but the real payoff comes when fzf gets embedded into your own Bash scripts. Instead of writing a script that prints a numbered list and waits for a digit, you feed the list into fzf and get the chosen line back as a result.
The difference becomes obvious with long lists: operating a menu of 40 Docker containers or 60 Git branches by number is tedious and error prone. With fzf the user simply types a few letters of the name they are looking for, and the list filters in real time. That not only makes scripts faster to use, it also makes them more approachable for new team members who do not need to memorize the exact spelling. The following sections show, step by step, how fzf becomes a full part of a Bash script.
2. Core principle: fzf as a filter between two pipes
The core principle of fzf is simple: it reads lines from stdin, shows them in an interactive window, and writes the line the user selected back to stdout. That fits neatly into the Unix pipe philosophy that already shapes the rest of a Bash script. A simple call like ls | fzf already demonstrates how the tool works without a single line of custom code.
For production use in scripts it is essential that fzf itself performs no action. It only returns the selection, and the subsequent processing remains entirely the responsibility of the Bash script. That separation makes fzf a pure selection tool that can be combined with arbitrary logic, from a simple echo to a complex deployment routine.
#!/usr/bin/env bash
# minimal-fzf-example.sh — the simplest possible fzf pipeline
set -euo pipefail
# fzf reads lines from stdin, writes the chosen line to stdout
selected_file=$(find . -maxdepth 2 -type f -name "*.log" | fzf --prompt="Select log> ")
if [[ -z "$selected_file" ]]; then
echo "No selection made, aborting." >&2
exit 1
fi
echo "Selected: $selected_file"
tail -n 50 "$selected_file"
3. First integration: capturing the selection in a variable
The standard way to plug an fzf selection into a Bash script is command substitution: variable=$(source | fzf). If the user cancels with Esc or Ctrl-C, fzf returns a non-zero exit code and the variable stays empty. Every robust script must explicitly check for exactly this case, otherwise the rest of the logic continues with an empty variable and produces confusing errors.
A second important detail concerns set -e combined with fzf. Since cancelling the selection produces an error code, set -e would terminate the script the moment the user presses Esc. In most cases that is even desirable, but if you want a custom error message instead, catch the exit code explicitly with || true and then check the variable content. That keeps control over the flow inside the script instead of an implicit abort.
#!/usr/bin/env bash
set -euo pipefail
choose_branch() {
local branch
# Capture exit code without letting set -e kill the script on Esc/Ctrl-C
branch=$(git branch --format='%(refname:short)' | fzf --prompt="Branch> " --height=40%) || true
if [[ -z "$branch" ]]; then
echo "[INFO] No selection made." >&2
return 1
fi
echo "$branch"
}
if selected_branch=$(choose_branch); then
git checkout "$selected_branch"
else
echo "Aborted." >&2
exit 1
fi
4. Preview window: showing context for the selection
A plain line filter is enough for simple lists, but quickly becomes confusing for more complex selections. The --preview option solves this by opening a second pane that runs an arbitrary command for the currently highlighted line and shows its output live. For a file list that can be cat or bat, for a container list docker inspect, for a commit list git show.
The placeholder {} in the preview definition is automatically replaced by fzf with the currently selected line. With just a few characters you can build a context window that immediately shows the user whether the highlighted line is really the one they are looking for, before they confirm with Enter. For scripts that need to distinguish between similarly named resources, for example several environments with almost identical names, the preview window is often the deciding factor between a risky and a safe selection.
#!/usr/bin/env bash
set -euo pipefail
# Preview shows the last 15 log lines of the selected container
container=$(docker ps --format '{{.Names}}' | \
fzf --prompt="Container> " \
--preview 'docker logs --tail 15 {} 2>&1' \
--preview-window=right:60%:wrap) || true
if [[ -z "$container" ]]; then
echo "No selection made." >&2
exit 1
fi
docker exec -it "$container" /bin/sh
5. Multi-select with -m and processing the results
Many scripting tasks involve not just one but several elements at once, for example archiving several log files or deleting several feature branches. The option -m or --multi enables multi selection in fzf, marked with the Tab key. Every marked line is printed as its own line on confirmation with Enter, separated by newlines.
For processing in Bash that means: the output of fzf belongs in an array, not in a single string variable, so that entries with spaces are also handled correctly. The proven Bash pattern for this is mapfile -t array < <(fzf-command), which stores every line of the output as its own array element. You then iterate with for item in "${array[@]}" over the selection, exactly as with any other safely populated array.
#!/usr/bin/env bash
set -euo pipefail
# Multi-select: mark entries with Tab, confirm with Enter
declare -a selected_logs=()
mapfile -t selected_logs < <(
find /var/log/app -name "*.log" -mtime +14 | \
fzf -m --prompt="Logs (Tab to mark)> " --height=60%
)
if [[ ${#selected_logs[@]} -eq 0 ]]; then
echo "No logs selected." >&2
exit 0
fi
echo "Archiving ${#selected_logs[@]} files..."
for log in "${selected_logs[@]}"; do
gzip -9 "$log" && echo "[OK] $log"
done
6. Practical example: Git branch and container switcher
A Git branch switcher is one of the most popular fzf integrations because it elegantly solves an everyday problem: instead of running git branch, typing the name and risking typos, you pick the branch from a filtered list. Combine that with a preview window showing the latest commits of the respective branch, and you get a tool that is faster than any graphical Git interface for this one use case.
The same pattern works for Docker containers, Kubernetes pods, or SSH hosts from an .ssh/config. The common denominator is always the same: produce a list, hand it to fzf, process the result in Bash. Once this pattern has been internalized, you find a spot in almost every own script where a rigid list can be replaced by a searchable fzf selection without touching the underlying logic.
7. Adjusting key bindings and layout
The --bind option lets you define custom key combinations, for example to reload the view with ctrl-r or to copy the selection directly to the clipboard with ctrl-y. For scripts used regularly it is worth showing a small header with --header that displays the available key combinations to the user instead of hiding them in documentation.
The layout option --layout=reverse shows the input line at the top instead of the bottom, which is often clearer in scripts with long output because the cursor stays directly below the last printed line. Setting the height with --height=40% prevents fzf from taking over the entire screen and keeps the context of the calling script visible. These small adjustments often decide whether a script feels like a well thought out tool or a quick hack.
8. Fallback: staying robust without fzf
Not every target environment has fzf installed, especially minimal container images or older production servers. A script that assumes fzf without checking will fail there with a cryptic "command not found" message. The robust approach checks with command -v fzf &>/dev/null whether the tool is available, and otherwise falls back to a simple but functioning select menu.
This fallback does not need to offer identical functionality. It is enough if it solves the same task with built-in tools, even if the usability is lower. This safeguard is especially important for scripts meant to run in different environments: comfortable with fzf locally, still functional in a CI container or on freshly provisioned servers without manual reinstallation.
#!/usr/bin/env bash
set -euo pipefail
choose_from_list() {
local -a items=("$@")
local choice
if command -v fzf &>/dev/null; then
choice=$(printf '%s\n' "${items[@]}" | fzf --prompt="Choice> ") || true
else
echo "[INFO] fzf not found, falling back to select" >&2
PS3="Choice (number)> "
select choice in "${items[@]}"; do
[[ -n "$choice" ]] && break
done
fi
echo "$choice"
}
targets=("staging" "production" "canary")
selected=$(choose_from_list "${targets[@]}")
echo "Deploying to: $selected"
9. fzf compared to select, read and whiptail
Several tools are available for interactive selection menus in Bash, differing in usability, dependencies and use case. fzf shines where lists are long or hard to overview, while the built-in select requires no external dependency and is fully sufficient for short, fixed lists.
| Tool | Dependency | Fuzzy search | Best use case |
|---|---|---|---|
| fzf | External (package manager) | Yes | Long, dynamic lists with preview |
| select (builtin) | None | No | Short, fixed lists without extra packages |
| read -p | None | No | Single free-text or yes/no inputs |
| whiptail / gum | External (usually preinstalled) | Partial | Graphical TUI dialogs with borders |
In practice these tools do not exclude each other. A well built script checks the availability of fzf and uses it where it adds the most value, while staying with read -p for simple yes/no confirmations. This pragmatic combination delivers the best usability without introducing unnecessary dependencies for trivial cases.
Mironsoft
Shell automation and CLI tooling for development teams
Scripts that feel like well designed tools?
We integrate fzf and other interactive CLI tools into your Bash scripts, with preview windows, multi-select and solid fallbacks for environments without fzf.
fzf integration
Retrofitting selection menus with preview and multi-select into existing scripts
CLI UX review
Reviewing existing scripts for usability and fallback robustness
Deployment tooling
Building interactive selection for branches, containers and environments
10. Summary
fzf solves a concrete problem in Bash scripts: rigid, hard to operate lists become searchable, quickly filterable selection menus. The command substitution variable=$(source | fzf) is the basic building block of every integration, complemented by explicit checking for empty results in case of a cancel. Preview windows with --preview provide context for the current selection, multi-select with -m allows marking several entries for batch operations.
The biggest lever for productive scripts lies in combining fzf for comfortable selection with a working fallback for environments without the tool. That keeps a script runnable everywhere while offering the greatest possible usability locally and interactively. Once fzf has been integrated into one script, you usually quickly find further spots in your own tooling where rigid lists can be replaced with the same technique.
fzf in Bash — The essentials at a glance
Core pattern
variable=$(source | fzf) plus explicit check for empty result on cancel with Esc or Ctrl-C.
Preview
--preview 'command {}' shows context for the highlighted line before the user confirms.
Multi-select
-m plus mapfile -t array < <(fzf-command) for safe processing of multiple selections.
Fallback
Check command -v fzf and fall back to select when fzf is not installed.