Skip to content

fzf: Fuzzy Finding Anything on the Command Line

There is a file. You know it exists, you know roughly what it’s called, and you know it’s somewhere under this directory. What you don’t know is the path, so you run ls, then cd, then ls again, then cd .. because that was the wrong one — navigating your own project by touch, like a man feeling for the light switch in a hotel room.

Meanwhile Ctrl+R searches your shell history by exact substring, which requires you to remember the beginning of a command you are searching for because you have forgotten it.

fzf fixes both, and roughly forty other things you hadn’t thought to be annoyed about yet. Written by Junegunn Choi in Go, it does precisely one thing: take a list on standard input, let you type at it, print what you picked. Every impressive demo you have seen — the git branch switcher, the process killer, the file picker with syntax-highlighted previews — is that single behaviour with a different list plugged into the front.

You want Command
Pick a file, interactively vim "$(fzf)"
Fuzzy history search Ctrl+R
Insert a file path into the line Ctrl+T
Jump to a subdirectory Alt+C
Pick from any list at all <anything> | fzf
Pick several <anything> | fzf -m (Tab to mark)
See what you’re choosing fzf --preview 'bat --color=always {}'

The key bindings need the shell integration below. Everything else works the moment fzf is installed.

Terminal window
brew install fzf # macOS
sudo apt install fzf # Debian, Ubuntu
sudo dnf install fzf # Fedora, RHEL

Check the version, because it matters here more than it usually does:

Terminal window
fzf --version

fzf moves quickly and distributions do not, so the copy your package manager hands you may be some years behind the documentation you’re reading. Then add one line to your shell config:

~/.zshrc
eval "$(fzf --zsh)"
~/.bashrc
eval "$(fzf --bash)"
~/.config/fish/config.fish
fzf --fish | source

That single line installs the key bindings and the completion machinery. Open a new shell and press Ctrl+R.

This is the whole of the shell integration, and it’s the reason fzf ends up on every machine you own.

Key Does Configure with
Ctrl+T Picks files and directories, pastes the paths FZF_CTRL_T_COMMAND, FZF_CTRL_T_OPTS
Ctrl+R Fuzzy-searches shell history, replaces the line FZF_CTRL_R_OPTS
Alt+C Picks a subdirectory and cds into it FZF_ALT_C_COMMAND, FZF_ALT_C_OPTS

Three details that aren’t obvious and change how much use you get out of them:

Ctrl+T types, it doesn’t run. It inserts the paths you picked at the cursor and hands the line back to you, so the natural rhythm is to write git add , press Ctrl+T, mark three files with Tab, press Enter, and then look at the assembled command before committing to it. Multi-select is on by default here.

Ctrl+R inherits what you’ve already typed. Start typing docker ru, realise you can’t remember the rest, and press Ctrl+R — the query is pre-filled with the line so far rather than thrown away. The history is deduplicated on the way in, and pressing Ctrl+R a second time inside the finder toggles between best-match and chronological ordering, which is what you want when you’re after “the one I ran on Tuesday” rather than “the one I run constantly”.

Alt+C runs immediately. It replaces the line with a cd to the absolute path and executes it. If nothing happens when you press it, you’re probably on a Mac — see the gotchas.

The integration also installs a completion trigger. Type ** and press Tab anywhere you’d normally expect completion, and fzf takes over:

Terminal window
vim src/**<TAB> # files under src/
cd ~/git/**<TAB> # directories only
ssh **<TAB> # hosts from your SSH config and known_hosts
kill -9 **<TAB> # processes, with the PID inserted for you
export **<TAB> # environment variables
unset **<TAB> # ditto

The kill one is worth internalising: it lists processes and inserts the PID, which removes the entire ps aux | grep-then-copy-the-number ritual. The trigger sequence is FZF_COMPLETION_TRIGGER if ** clashes with something in your muscle memory. This is a Bash and Zsh feature — the fish integration gives you the key bindings only.

Host completion reads the same ~/.ssh/config that makes your connections readable, so the two compound: name your hosts once, then never type a hostname again.

Typing into fzf performs a fuzzy match: the characters you type must appear in order, but not adjacently. srmn finds src/main.rs. That is the default, and most people stop there.

There are five operators, and they turn fzf from a guessing game into something you can aim. Given a list of file paths:

You type You get
srmn Fuzzy: src/main.rs, src/main_test.rs
'main Exact substring: anything containing main
^src Prefix: paths starting src
.rs$ Suffix: Rust files
!test Negation: everything without test
md$ | js$ Either: Markdown or JavaScript

Terms separated by spaces are ANDed, so the pattern you’ll actually reach for looks like this:

'rs !test

Exact rs, excluding anything matching test. Half of using fzf well is remembering that ! exists; the number of times the thing you want is best described as “not the other one” is remarkable.

Two flags worth knowing alongside it: -e (--exact) inverts the default so everything is an exact match unless you prefix it with a fuzzy operator, and --nth restricts matching to particular fields:

Terminal window
ps -ef | fzf --nth 8.. # match against the command, not the PID or timestamp

Without that, searching a process list for 2 matches every PID containing a 2, which is most of them.

fzf can run a command for whatever’s currently highlighted and show the output beside the list. {} is replaced with the current line:

Terminal window
fzf --preview 'bat --color=always --style=numbers {}'

Now you’re not picking from a list of paths, you’re reading the file before you open it. The same trick works on anything you can render:

Terminal window
# directories, with contents
fd --type d | fzf --preview 'eza --tree --level=2 --colour=always {}'
# git branches, with recent commits
git branch --format='%(refname:short)' |
fzf --preview 'git log --oneline --color=always -20 {}'

Placeholders are more capable than {} suggests. {1}, {2} and so on give you individual fields, {q} is the current query, and {+} is every selected item. With --delimiter you can carve up structured output — the reason the git log recipe below works at all.

Position and behaviour come from --preview-window:

Terminal window
--preview-window 'right,60%,border-left,wrap'
--preview-window 'up,40%,hidden' # off until you ask for it
--bind 'ctrl-/:toggle-preview'

fzf’s defaults are conservative: full-screen, prompt at the bottom, no border. Set FZF_DEFAULT_OPTS once and every invocation — including the key bindings — inherits it:

Terminal window
export FZF_DEFAULT_OPTS="
--height 40%
--layout=reverse
--border
--info=inline
--preview-window 'right,60%,border-left,wrap'
--bind 'ctrl-/:toggle-preview'
--bind 'ctrl-y:execute-silent(echo {} | pbcopy)'
"

--height 40% is the important one: fzf opens below the cursor instead of taking over the screen, so you can still see the command you were in the middle of writing. --layout=reverse puts the prompt at the top, where you are already looking. Recent versions collapse most of the cosmetic flags into --style=full, if you’d rather have one line than seven.

ctrl-y there copies the highlighted line to the clipboard without leaving the finder — pbcopy on macOS, wl-copy or xclip -selection clipboard on Linux. execute-silent runs a command and returns you to the list; plain execute hands over the terminal, which is how people build file managers out of this thing and then have to explain to their colleagues why.

If a multi-line environment variable offends you, newer versions read a config file from FZF_DEFAULT_OPTS_FILE:

Terminal window
export FZF_DEFAULT_OPTS_FILE=~/.config/fzf/fzfrc

Either way, that belongs in your dotfiles repo rather than in the shell history of one laptop.

Run fzf with nothing piped into it and it walks the current directory itself, skipping .git and node_modules. Fine, but fd is faster and already knows about .gitignore:

Terminal window
export FZF_DEFAULT_COMMAND='fd --type f --hidden --exclude .git'
export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"
export FZF_ALT_C_COMMAND='fd --type d --hidden --exclude .git'

The second line is not redundant. FZF_DEFAULT_COMMAND applies to bare fzf; the widgets read their own variables and fall back to the built-in walker rather than to your default, so Ctrl+T will happily go on listing build artefacts until you tell it not to.

fd and its friends live in the modern CLI starter pack, which is where fzf itself gets a shorter and less opinionated write-up.

fzf needs a terminal, which makes it a poor fit for anything running unattended — but it is excellent in the interactive scripts and shell functions you write for yourself. Four flags do most of the work:

Terminal window
fzf -1 -0 -q "$1"

-q seeds the query, -1 selects automatically when exactly one thing matches, and -0 exits without prompting when nothing does. Together they mean your function only interrupts you when the answer is genuinely ambiguous, which is the difference between a helper and a nuisance.

Exit codes are unambiguous: 0 if something was selected, 1 if nothing matched, 2 for a bad option, 130 if the user pressed Escape. So the safe shape for a function is:

Terminal window
select_file() {
local file
file=$(fd --type f | fzf -1 -0 -q "${1-}") || return # abort cleanly on Esc
printf '%s\n' "$file"
}

For multiple selections, filenames with spaces in them will eventually find you. Use NUL separators and stop worrying:

Terminal window
fd --type f | fzf -m --print0 | xargs -0 -o vim

-o is the flag people leave out and then wonder why vim opens complaining that input is not from a terminal: it reopens stdin as the terminal before running the command, which anything interactive on the end of an xargs needs.

And when you want fuzzy matching without any interface at all, -f (--filter) is fzf as a plain filter — it takes the pattern as an argument, prints the matches ranked by score, and never opens a window. Useful in pipelines, and by far the easiest way to work out why a pattern isn’t matching what you expected.

Switch git branches. The one that justifies the install:

Terminal window
gco() {
local branch
branch=$(git branch --all --format='%(refname:short)' |
grep -v '^origin/HEAD$' |
fzf --preview 'git log --oneline --color=always -20 {}') || return
git switch "${branch#origin/}"
}

Browse the log and open a commit. --ansi keeps git’s colours, +s preserves chronological order rather than re-sorting by match score, and {1} hands the preview the short hash:

Terminal window
git log --oneline --color=always |
fzf --ansi +s --preview 'git show --color=always {1}' |
awk '{print $1}'

Kill something. --nth 8.. keeps the search on the command rather than the numbers:

Terminal window
ps -ef | sed 1d | fzf -m --nth 8.. --header='Tab to mark, Enter to kill' |
awk '{print $2}' | xargs kill

Add -9 when the process has earned it, which is less often than the internet implies.

Search file contents, live. fzf’s reload action re-runs a command on every keystroke, which turns it into an interactive ripgrep. --disabled stops fzf from also fuzzy-filtering the results, so what you type goes straight to rg:

Terminal window
fzf --disabled --ansi --delimiter : \
--bind 'start:reload:rg --column --line-number --no-heading --color=always --smart-case ""' \
--bind 'change:reload:rg --column --line-number --no-heading --color=always --smart-case {q} || true' \
--preview 'bat --color=always --highlight-line {2} {1}' \
--preview-window 'up,60%,border-bottom,+{2}+3/3'

The || true matters: without it, every keystroke that matches nothing is a non-zero exit and fzf reports an error instead of an empty list.

Open it in a tmux popup. Since 0.53, --tmux floats the finder over your current pane rather than redrawing it:

Terminal window
export FZF_DEFAULT_OPTS="$FZF_DEFAULT_OPTS --tmux center,80%,60%"

Which pairs neatly with a tmux setup you’ve already got running.

Explore a JSON payload you don’t understand. fzf and jq make short work of an unfamiliar response — every leaf path in the document, with its value in the preview pane:

Terminal window
jq -c 'paths(scalars)' response.json |
fzf --preview 'jq -r --argjson p {} "getpath(\$p)" response.json'

The paths come out as JSON arrays rather than dotted strings, which looks uglier and is the only version that survives contact with arrays: ["servers",0,"name"] feeds straight back into getpath, whereas .servers.0.name is a syntax error.

1. Alt+C does nothing on macOS. The Option key sends composed characters — ç rather than Meta — so the binding never fires. Fix it in the terminal, not in fzf: Terminal.app has Settings → Profiles → Keyboard → Use Option as Meta key, iTerm2 has Profiles → Keys → Left Option key → Esc+, and Ghostty wants macos-option-as-alt = true in its config. Worth doing regardless — half the readline bindings in our keyboard shortcuts guide depend on the same setting, and the choice of terminal emulator determines how much of a fight it is.

2. vim $(fzf) breaks on spaces. The classic demo is unquoted and therefore wrong the first time you pick Design Notes.md. Quote it — vim "$(fzf)" — and use --print0 with xargs -0 for multiple files.

3. Your distribution’s fzf is older than the documentation. fzf --zsh needs 0.48, --tmux needs 0.53, --style is newer still. The symptom is always unknown option, and the answer is always fzf --version.

4. Results come back by match score, not in the order you piped them in. For history that’s exactly right; for git log, or anything where sequence is the information, it quietly destroys the data. +s (--no-sort) turns it off, and --tac reverses the input when the newest entries are at the bottom.

5. A preview that shells out to something slow makes the whole thing feel broken. It isn’t fzf being sluggish, it’s your preview command running once per highlighted line. Cap the output and it snaps back.

  • For directories you visit constantly, zoxide is less typing than even Alt+C — it learns your habits and jumps on a fragment. The two coexist happily, and it’s covered in the starter pack.
  • For non-interactive filtering, use grep or rg. fzf’s -f mode is fuzzy, which is wonderful for humans and a liability in a cron job where you’d rather match exactly or fail.
  • If you’d rather it were Rust, skim (sk) covers most of the same ground with a compatible-ish interface. It’s a fine tool and not a reason to postpone learning fzf, given every recipe on the internet assumes fzf.

The natural home for the functions above is a dotfiles repo, so the machine you SSH into next week has them too. If your .zshrc is currently a plugin framework wearing a shell as a disguise, Zsh without the bloat covers the forty lines that replace it — fzf included. And the modern CLI starter pack has the other six tools worth the install, most of which make better fzf inputs than the ones they replace.