Installation and Version Check
The fastest install method depends on your distro. On Debian 12 and Ubuntu 24.04, the packaged version lags behind upstream - apt will give you 0.38 or older. Use the official install script or pull the binary directly from GitHub releases to get 0.54.
On RHEL 9 / Rocky 9, the EPEL repository carries a reasonably current build, but again, check the version before relying on features like the `--tmux` flag which was added in 0.47.
After installing, confirm the build:
# GitHub release install (all distros)
curl -Lo /tmp/fzf.tar.gz https://github.com/junegunn/fzf/releases/download/v0.54.3/fzf-0.54.3-linux_amd64.tar.gz
tar -xzf /tmp/fzf.tar.gz -C /usr/local/bin/
chmod +x /usr/local/bin/fzf
# Verify
fzf --version
# fzf 0.54.3 (brew)
# Debian/Ubuntu (older, but works for basic use)
apt install fzf
# RHEL/Rocky via EPEL
dnf install epel-release && dnf install fzf
Shell Integration: The Part Most People Skip
Installing the binary without wiring up the shell integration leaves 80% of fzf's value on the table. The integration adds three critical bindings: `Ctrl-T` for file search, `Ctrl-R` for history search, and `Alt-C` for directory navigation. It also enables fuzzy completion via `**` on the command line.
The install script can set this up automatically, or you can run the shell-specific setup manually. For bash, source the completion and key-binding scripts in your `.bashrc`. For zsh, the same applies to `.zshrc`. Fish has its own plugin path.
After sourcing, open a new shell and test `Ctrl-R`. If your history appears in a fuzzy-searchable list, the integration is working. If not, check that the scripts exist at the paths below - on EPEL installs they land in `/usr/share/fzf/` instead of the default home directory path.
# Run the post-install setup script
$(fzf --bash) # bash - adds to current session
# Or permanently:
echo 'eval "$(fzf --bash)"' >> ~/.bashrc
# Zsh
echo 'eval "$(fzf --zsh)"' >> ~/.zshrc
# Fish
fzf --fish | source
# Manual path for EPEL/apt installs
cat >> ~/.bashrc << 'EOF'
source /usr/share/fzf/completion.bash
source /usr/share/fzf/key-bindings.bash
EOF
source ~/.bashrc
Core Usage: Piping Anything Through fzf
fzf reads from stdin and writes the selected item to stdout. That single contract makes it composable with any Unix pipeline. The basic mental model is: generate a list, filter it with fzf, act on the result.
The `--preview` flag is where fzf becomes genuinely useful for sysadmins. You can attach any command to preview the highlighted item before selecting it. We use `--preview` with `bat` for files, `docker inspect` for containers, and `kubectl describe` for Kubernetes resources daily.
Multi-select with `-m` lets you tab-mark several entries and pass them all to the next command. Combined with `xargs`, this replaces a lot of for-loop scripting.
# Basic: pick a file and open it
vim $(fzf)
# With syntax-highlighted preview (requires bat)
fzf --preview 'bat --color=always --line-range :100 {}'
# Multi-select files and delete them (careful)
fzf -m --preview 'ls -lh {}' | xargs rm -i
# Search process list and kill selected
ps aux | fzf -m | awk '{print $2}' | xargs kill -9
# Grep results, pick one, jump to line in vim
grep -n 'CRITICAL' /var/log/app/error.log | fzf | cut -d: -f1 | xargs -I{} vim +{} /var/log/app/error.log
History Search with Ctrl-R: Better Than the Default
Bash's built-in `Ctrl-R` is reverse-incremental and only shows one result at a time. fzf replaces it with a full list sorted by recency, searchable by any substring or abbreviation. Typing `dnf upd` finds `sudo dnf update -y` immediately. Typing `kctl ns` finds `kubectl config set-context --current --namespace=staging`.
The default fzf history search does not deduplicate entries. To get a clean, deduplicated history list, set `HISTCONTROL=ignoreboth:erasedups` in your `.bashrc` before sourcing fzf. On systems shared by multiple admins - which is common in legacy environments - you can point fzf at a team-shared history file, though you need to handle locking carefully.
One option worth setting globally is `--history-size`. By default fzf only loads 10,000 history entries. If you have a long-running server where bash history accumulates over years, set it higher:
# In ~/.bashrc before the fzf eval line
export HISTCONTROL=ignoreboth:erasedups
export HISTSIZE=50000
export HISTFILESIZE=100000
# Increase fzf history size
export FZF_CTRL_R_OPTS="--history-size=50000 --sort --exact"
# Full Ctrl-R config with preview of the command
export FZF_CTRL_R_OPTS="
--preview 'echo {}'
--preview-window up:3:wrap
--bind 'ctrl-/:toggle-preview'
--color header:italic
--header 'CTRL-/ to toggle preview'
"
Environment Variables: Configuring Default Behavior
Rather than typing flags on every invocation, set your defaults in `FZF_DEFAULT_OPTS`. This variable is applied to every fzf call, including the shell integration bindings. Keep it to layout and behavior flags - if you add `--preview` here it runs on every `Ctrl-T` press, which can be slow on network-mounted filesystems.
`FZF_DEFAULT_COMMAND` controls what fzf uses when invoked with no input and no pipe. By default it runs `find`. Replacing it with `fd` (the Rust-based find replacement) gives you gitignore awareness and a 3-5x speed improvement on large directory trees. We measured 2.1 seconds vs 0.4 seconds on a directory with 180,000 files on our test server.
`FZF_CTRL_T_COMMAND` overrides the command specifically for the `Ctrl-T` binding, letting you keep a different default for bare fzf invocations.
# ~/.bashrc or ~/.profile
export FZF_DEFAULT_OPTS='
--height 40%
--layout=reverse
--border
--info=inline
--bind "ctrl-/:toggle-preview"
--color=fg:#d0d0d0,bg:#121212,hl:#5f87af
--color=fg+:#d0d0d0,bg+:#262626,hl+:#5fd7ff
--color=info:#afaf87,prompt:#d7005f,pointer:#af5fff
'
# Use fd instead of find (install: dnf install fd-find / apt install fd-find)
export FZF_DEFAULT_COMMAND='fd --type f --hidden --follow --exclude .git'
export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"
SSH Host Fuzzy Search: A Workflow That Saves Real Time
On infrastructure with 50+ hosts, `ssh
The preview window shows the relevant config block so you can confirm you are connecting to the right host and identity file before pressing Enter. Add the function to your `.bashrc` and bind it to a key if you use it frequently.
# Add to ~/.bashrc
fssh() {
local host
host=$(grep -E '^Host ' ~/.ssh/config 2>/dev/null | \
grep -v '\*' | \
awk '{print $2}' | \
fzf --preview 'ssh-config-preview() { grep -A 20 "^Host $_" ~/.ssh/config; }; grep -A 20 "^Host {}" ~/.ssh/config' \
--preview-window right:50% \
--header 'Select SSH host')
[[ -n "$host" ]] && ssh "$host"
}
# Bind to Alt-S
bind -x '"\es": fssh'
Docker and Kubernetes Integration
fzf pairs naturally with container tooling because both `docker` and `kubectl` produce columnar list output. The pattern is always the same: run the list command, pipe to fzf with a preview, extract the identifier from the selected line, pass to the action command.
For Kubernetes, the most useful binding in a multi-cluster environment is fuzzy namespace switching. Typing `kns` and searching is faster than tab-completing `kubectl config set-context`. For teams doing heavy DevOps automation, pairing fzf-driven shell functions with a workflow orchestration layer like taskbotshub.ai lets you expose curated fzf menus as bot commands, so junior team members get interactive container management without needing full kubectl access.
The docker log preview in the function below uses `--tail 50` to avoid blocking on containers with gigabyte log files.
# Docker: fuzzy exec into a running container
dex() {
local container
container=$(docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}' | \
tail -n +2 | \
fzf --header 'Select container' \
--preview 'docker logs --tail 50 $(echo {} | awk "{print \$1}")' \
--preview-window right:60% | \
awk '{print $1}')
[[ -n "$container" ]] && docker exec -it "$container" /bin/sh
}
# Kubernetes: fuzzy namespace switch
kns() {
local ns
ns=$(kubectl get namespaces -o name | \
sed 's|namespace/||' | \
fzf --preview 'kubectl get all -n {}' \
--preview-window right:60%)
[[ -n "$ns" ]] && kubectl config set-context --current --namespace="$ns"
}
# Kubernetes: fuzzy pod log viewer
klogs() {
local pod
pod=$(kubectl get pods | tail -n +2 | \
fzf --preview 'kubectl describe pod $(echo {} | awk "{print \$1}")' | \
awk '{print $1}')
[[ -n "$pod" ]] && kubectl logs -f "$pod"
}
Git Integration: Log Search, Branch Switch, Stash Management
The two git workflows that benefit most from fzf are branch switching and log navigation. Checking out branches by typing partial names is faster than `git branch | grep partial && git checkout exact-name`. Log search with diff preview turns fzf into a lightweight git GUI.
The log function below is the one we use most. It shows a color graph on the left, and the full diff for the highlighted commit in the preview window. Pressing Enter checks out the commit, which is useful for bisecting issues. Pressing `Ctrl-Y` copies the hash to the clipboard via xclip - useful for cherry-picks.
For teams that name branches with ticket numbers and project names, consistent naming makes fzf search significantly more effective. If you are also registering project-specific domains alongside your repository names, the same naming discipline applies - services like nicename.me can help you find available domain names that match your internal project slug, keeping your infrastructure naming coherent from git branch to subdomain.
# Fuzzy branch checkout
fgb() {
local branch
branch=$(git branch -a | \
grep -v HEAD | \
sed 's/^..//;s/ .*$//' | \
fzf --preview 'git log --oneline --graph --color=always -20 {}' \
--preview-window right:60%)
[[ -n "$branch" ]] && git checkout "$(echo $branch | sed 's|remotes/origin/||')"
}
# Fuzzy git log with diff preview
fgl() {
git log --oneline --color=always | \
fzf --ansi \
--preview 'git show --color=always $(echo {} | cut -d" " -f1)' \
--preview-window right:70% \
--bind 'enter:execute(git checkout $(echo {} | cut -d" " -f1))' \
--bind 'ctrl-y:execute(echo {} | cut -d" " -f1 | xclip -selection clipboard)'
}
# Fuzzy stash apply
fgst() {
local stash
stash=$(git stash list | \
fzf --preview 'git stash show -p $(echo {} | cut -d: -f1)' \
--preview-window right:60% | \
cut -d: -f1)
[[ -n "$stash" ]] && git stash apply "$stash"
}
File Content Search with ripgrep and fzf
Combining ripgrep with fzf creates an interactive codebase search that updates as you type. The key is using `fzf --disabled` to turn off fzf's own filtering and let ripgrep handle the search, then rebinding the query change event to re-run ripgrep with the current input.
This pattern was introduced via `--bind 'change:reload'` and requires fzf 0.21 or later. The result is a live-updating file search where you type a pattern, ripgrep re-scans the directory, and fzf displays the results with a preview window showing the file and the matching line highlighted. We measured ripgrep scanning a 2GB codebase in 1.8 seconds on a standard NVMe drive.
This replaces `grep -rn pattern . | fzf` for any non-trivial codebase because it handles binary file exclusion, gitignore rules, and color output automatically.
# Interactive ripgrep + fzf search
# Requires: rg (ripgrep), bat, fzf 0.21+
frg() {
local initial_query="${*:-}"
RG_PREFIX="rg --column --line-number --no-heading --color=always --smart-case"
fzf --disabled \
--ansi \
--query "$initial_query" \
--bind "start:reload:$RG_PREFIX {q}" \
--bind "change:reload:sleep 0.1; $RG_PREFIX {q} || true" \
--delimiter : \
--preview 'bat --color=always {1} --highlight-line {2}' \
--preview-window 'right:60%,+{2}+3/3,~3' \
--bind 'enter:become(vim {1} +{2})'
}
Performance Tuning for Large Inputs
fzf is fast by default but a few options matter when you are filtering millions of lines. The `--algo` flag controls the matching algorithm: `v1` is faster for large inputs, `v2` (default) gives better ranking on small inputs. On inputs over 500,000 lines, switching to `--algo=v1` cuts latency noticeably.
`--no-sort` disables result sorting, which is a significant speedup when the input is already ordered by relevance - for example, when piping from a recent-first log file.
For network-mounted filesystems (NFS, CIFS), avoid running `FZF_DEFAULT_COMMAND` against the mount point at shell startup. Instead, either set the default command to search only local paths, or use `--bind 'load:first'` to ensure fzf starts rendering immediately without waiting for a full directory scan.
Memory usage scales roughly linearly with input size. In our tests, fzf held about 80MB RSS for a 1-million-line input. For processes where that matters - shared hosts with tight memory limits - pipe through `head -n 100000` before fzf rather than letting it ingest the full dataset.
# Fast mode for large inputs
cat /var/log/huge-access.log | fzf \
--algo=v1 \
--no-sort \
--tac \
--nth=7 \
--delimiter=' ' \
--preview 'echo {}'
# Limit input on memory-constrained hosts
tail -n 50000 /var/log/app/combined.log | fzf --tac
# Check fzf memory on a given invocation (run in parallel)
watch -n1 'ps -o pid,rss,args | grep fzf | grep -v grep'
Using fzf Inside Scripts
fzf works in non-interactive contexts only if stdin is a TTY. In scripts called by cron or CI pipelines, fzf will exit immediately because there is no terminal. The fix is to redirect stdin from `/dev/tty` explicitly. This is also the pattern for scripts that build menus for operators to run manually.
For scripts that need to degrade gracefully when run non-interactively, check `[ -t 0 ]` (stdin is a terminal) before invoking fzf and fall back to positional arguments or environment variables.
The `--expect` flag lets you capture which key the user pressed to confirm their selection. This enables multi-action menus: pressing Enter does one thing, pressing `Ctrl-D` does another, pressing `Ctrl-E` opens an editor. This pattern is useful for deployment scripts where the operator needs to choose between apply, dry-run, and inspect.
#!/usr/bin/env bash
# deploy-menu.sh - interactive deployment target selector
ENVS=(production staging dev-eu dev-us)
if [ -t 0 ]; then
# Interactive: use fzf
result=$(printf '%s\n' "${ENVS[@]}" | \
fzf --expect=ctrl-d,ctrl-e \
--header 'Enter=deploy | Ctrl-D=dry-run | Ctrl-E=edit config' \
< /dev/tty)
key=$(head -1 <<< "$result")
target=$(tail -1 <<< "$result")
else
# Non-interactive: require argument
target="${1:?Usage: deploy-menu.sh }"
key=''
fi
case "$key" in
ctrl-d) echo "Dry run: $target" ;;
ctrl-e) "${EDITOR:-vim}" "configs/$target.yml" ;;
*) echo "Deploying to: $target" ;;
esac
tmux Integration with --tmux Flag
fzf 0.47 added the `--tmux` flag, which opens fzf in a tmux popup instead of taking over the current pane. This is the single biggest quality-of-life improvement for sysadmins who live in tmux sessions with split panes. Previously you had to pipe through `tmux display-popup` manually with several flags.
The `--tmux` argument accepts a size specification: `center,80%,60%` opens an 80%-wide, 60%-tall popup centered in the terminal. This keeps your current pane visible behind the fzf window, which is particularly useful when you are looking at logs in one split and want to search for a related file in another.
Set it in `FZF_DEFAULT_OPTS` only if you always work in tmux. If you sometimes SSH into hosts without tmux, the flag causes fzf to fail with an error when `$TMUX` is unset. The safer approach is a conditional:
# Conditional tmux popup based on whether we're inside tmux
if [[ -n "$TMUX" ]]; then
export FZF_DEFAULT_OPTS="$FZF_DEFAULT_OPTS --tmux center,80%,60%"
fi
# Or per-invocation:
fzf --tmux center,80%,60% --preview 'bat --color=always {}'
# tmux popup for file search with preview
alias fzf-tmux='fzf --tmux 90%,80% \
--preview "bat --color=always --line-range :200 {}" \
--preview-window right:55%'