fzf in Bash: Building Interactive Selection Menus for Scripts
AI generated
$_
#!/
Bash · fzf · CLI UX · Linux
fzf in Bash: Building Interactive Selection Menus for Scripts
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.

18 min read fzf · preview · multi-select · fallback Bash 4.x · 5.x · Linux · macOS

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.

11. FAQ: fzf in Bash Scripts

1What is fzf?
A command line fuzzy finder that makes stdin lines interactively filterable and writes the selection to stdout.
2Capturing selection into a variable?
variable=$(source | fzf), then always check for empty content since a cancel does not force an error.
3Showing a preview window?
--preview 'command {}' with {} as a placeholder for the highlighted line, shows live context.
4Using multi-select?
-m enables multi selection with Tab. Read the result with mapfile -t into an array, not a single variable.
5fzf missing on server?
Check command -v fzf and fall back to select if missing, instead of letting the script abort cryptically.
6set -e and cancelling?
Esc or Ctrl-C produce a non-zero exit code. Catch it with || true to take over custom error handling.
7Building a Git branch switcher?
Pipe git branch --format into fzf, process the selection with git checkout, optionally --preview with git log.
8Custom key bindings?
--bind allows custom mappings like ctrl-r to reload or ctrl-y to copy the selection.
9Faster than select?
Noticeably for long lists, since fuzzy search saves counting numbers. Barely matters for short lists.
10Does fzf work in CI?
Not meaningfully, since an interactive terminal is missing. CI-capable scripts need a non-interactive fallback.