Skip to content

ripgrep (rg) Cheat Sheet and Guide

The ritual goes like this. grep -rn "fetchUser" ., Enter, and then a wall of minified JavaScript from node_modules scrolls past for eleven seconds, followed by a line from a build artefact you deleted in spirit last March, followed by Binary file .git/index matches. The one line you wanted is in there somewhere, and it is not on screen any more.

ripgrep — the binary is rg — is what happens when somebody decides the defaults were the problem, not you. It searches recursively without being asked, skips everything your .gitignore skips, ignores hidden files and binaries, uses every core you have, and prints results grouped by file with line numbers and colour. Written in Rust by Andrew Gallant, it is the engine behind VS Code’s search panel, which means you have almost certainly used it without knowing.

The short answer to “how do I use ripgrep” is rg pattern. The long answer is the eight or so flags that turn it from a faster grep into something you can aim, plus the four reasons it will one day refuse to find a file you can see with your own eyes.

The shape is rg [options] PATTERN [PATH...]. No path means the current directory, searched recursively. Quote patterns and globs, so the shell doesn’t get to them first.

Command Does
rg TODO Search this directory and everything in it
rg TODO src/ lib/ Search only these paths
rg -i todo Ignore case
rg -S todo Ignore case unless the pattern has a capital
rg -F 'a.b()' Literal string — . and ( mean themselves
rg -w id Whole words: id, not width
rg -v TODO Lines that don’t match
rg -e foo -e '-bar' Several patterns, or one that starts with -

By default rg skips gitignored, hidden and binary files. Why rg can’t see your file explains each rule; these flags turn them off or add new ones.

Command Does
rg -t py import Python files only
rg -T js TODO Everything except JavaScript
rg --type-list Every built-in type and the globs behind it
rg TODO -g '*.tsx' Only paths matching a glob
rg TODO -g '!node_modules/' Skip a directory, however deep
rg -u TODO Include gitignored files
rg -uu TODO …and hidden files, .git included
rg -uuu TODO …and binary files: roughly grep -r
rg --hidden -g '!.git' TODO Hidden files, minus the .git directory
rg -L TODO Follow symlinks
rg -z ERROR app.log.gz Search inside compressed files
Command Does
rg -l TODO Names of files with a match
rg --files-without-match TODO Names of files without one
rg -c TODO Matching lines per file
rg --count-matches TODO Matches per file
rg -o 'v\d+' Only the matched text, one per line
rg -C 3 panic 3 lines of context — -A after, -B before
rg -M 200 TODO Truncate lines over 200 columns
rg --files List the files rg would search, without searching
rg -l0 TODO | xargs -0 wc -l Hand filenames on safely, spaces and all
rg --json TODO One JSON object per event, for programs

-r changes only what rg prints. The last row is how you edit the files themselves — see search and replace.

Command Does
rg -P 'foo(?=Bar)' PCRE2: look-around and backreferences
rg -U 'fn main\(\) \{\n' Match across lines
rg 'v(\d+)' -r 'version $1' Show each match with a replacement applied
rg -o 'id=(\d+)' -r '$1' Print just a capture group
rg -l0 old | xargs -0 perl -pi -e 's/old/new/g' Replace in the files themselves
Command Does
rg -q TODO No output — exit 0 on a match, 1 without, 2 on error
rg --sort path TODO Stable order, at the cost of a single thread
rg --debug TODO Say why each file was searched or skipped
rg --no-config TODO Ignore your config file for this run

If a search comes back empty and you’re sure it shouldn’t, try -uu before anything else. It answers most of the “rg is broken” questions on the internet, and --debug answers the rest.

Terminal window
brew install ripgrep # macOS
sudo apt install ripgrep # Debian, Ubuntu
sudo dnf install ripgrep # Fedora, RHEL
winget install BurntSushi.ripgrep.MSVC # Windows

The package is ripgrep everywhere; the command is rg everywhere, including Debian, which for once has not renamed anything. Check what you got:

Terminal window
rg --version

The line to look for is features:+pcre2. Homebrew’s build has it; some distribution builds have historically not, and without it the -P flag below does nothing but apologise.

Run in a terminal, rg TODO groups matches under each filename, numbers the lines, and highlights the match:

Terminal window
src/api/users.js
2: // TODO: retry on 503
src/main.js
3:// TODO: remove before the demo

Two files. Not the TODO in node_modules/left-pad, not the one in the minified bundle under dist/ — both listed in .gitignore, so ripgrep never opened them.

Pipe the output anywhere and the format changes to grep’s familiar path:line shape with no colour and no line numbers, which is what xargs, cut and your editor’s quickfix list expect. If you want line numbers in a pipe, ask for them with -n; if you want the grouped layout in a pipe, --heading. Colour into a pager is rg --color=always TODO | less -R, though rg -p — --pretty — does colour, headings and line numbers in one flag.

This is the whole of ripgrep’s personality, and the source of every “rg is broken” thread on the internet. By default it skips four things:

  1. Anything matched by .gitignore, .ignore or .rgignore files
  2. Hidden files and directories — anything starting with a dot
  3. Binary files
  4. Symlinks

Each -u peels off a layer:

Flag Also searches Long form
(none) —
-u Ignored files --no-ignore
-uu …and hidden files --no-ignore --hidden
-uuu …and binary files --no-ignore --hidden --binary

-uuu is roughly grep -r, which is a useful way to think about what ripgrep is doing for you the rest of the time. Symlinks are separate: -L (--follow) follows them.

Four details that are not obvious and will each cost you ten minutes exactly once:

--hidden means .git too. Ask for hidden files and ripgrep will cheerfully search your repository’s object database, hooks and all. The clean version excludes it by glob:

Terminal window
rg --hidden -g '!.git' API_KEY

That combination is worth putting in a config file so it becomes the default.

.gitignore only counts inside a git repository. Copy a project somewhere without its .git directory — an unpacked tarball, a Docker build context — and ripgrep stops honouring the .gitignore, so dist/ comes back. --no-require-git makes it respect the file anyway.

node_modules is not special. ripgrep skips it only because your .gitignore lists it. A project that doesn’t ignore it gets it searched in full. When you want something skipped for searching but still tracked by git — fixtures, vendored code, a generated schema — list it in an .ignore file, which uses the same syntax and which git itself never reads.

A glob can’t reach into an ignored directory. ripgrep never descends into dist/, so rg TODO -g 'dist/**' finds nothing and tells you “No files were searched”. Name the path instead: rg TODO dist/. An explicitly named path is always searched, ignore rules or not.

When you genuinely can’t work out why a file is being skipped, --debug prints the reason for every decision it makes, which is verbose, and also the answer.

By file type. -t restricts to a type, -T excludes one. ripgrep ships definitions for a couple of hundred languages, so you don’t have to remember that TypeScript is also .mts, .cts and .tsx:

Terminal window
rg -t py 'import requests'
rg -t ts -t js useEffect # several types
rg -T js TODO # everything except JavaScript
rg --type-list | rg '^ts:' # what counts as "ts"

Missing one? --type-add defines it on the spot, and a config file makes it permanent:

Terminal window
rg --type-add 'web:*.{html,css,js}' -t web 'font-family'

By glob. -g includes paths matching a glob, and a leading ! excludes them. Globs use .gitignore syntax and can be repeated:

Terminal window
rg TODO -g '*.tsx'
rg TODO -g '!*_test.go' -g '!testdata'
rg TODO -g 'src/**'

Always quote globs, or your shell will expand them first and ripgrep will receive a list of filenames it didn’t ask for. Globs are case-sensitive; --iglob is the case-insensitive twin, for the repository where someone saved README.MD.

By directory. Excluding a directory is a glob with a trailing slash, which matches directories only — so node_modules.txt survives — at any depth. A leading slash pins it to the top of the search instead:

Terminal window
rg TODO -g '!node_modules/' # every node_modules, however deep
rg TODO -g '!/vendor/' # only ./vendor

If you’re excluding the same directory every time, it belongs in an .ignore file rather than your fingers.

Most of ripgrep’s matching flags are grep’s — -i, -F, -w, -v, -e — and they’re in the cheat sheet, along with smart case, which grep doesn’t have. The one not listed there is -x, for whole-line matches. Two deserve more than a table row.

Smart case is the first. rg -S todo finds TODO, Todo and todo; rg -S Todo finds only Todo, on the reasonable assumption that if you typed a capital you meant it. It is so plainly the right default that it goes straight into the config file below.

-F matters more than it does with grep, because people search code for code, and code is full of regex metacharacters. rg 'a.b()' is a regex matching axb followed by nothing in particular; rg -F 'a.b()' is the method call you pasted.

To search for something that looks like a flag, end the options first:

Terminal window
rg -- --force
rg -e '-v'

ripgrep uses Rust’s regex crate by default, which is fast because it guarantees linear-time matching — and it guarantees that by refusing the two features that can’t offer it. Ask for either and you get a clear error rather than a wrong answer:

Terminal window
rg 'foo(?=Bar)' # error: look-around ... is not supported
rg '(\w)\1' # error: backreferences are not supported

-P switches to PCRE2 for that search, which supports both. --engine auto tries the fast engine first and falls back to PCRE2 only when the pattern needs it — handy in a config file, if you would rather not think about it.

Otherwise the syntax is what you’d expect from any modern regex flavour: \d, \w, \b, [[:alpha:]], named groups as (?P<name>...), inline flags like (?i). Two things catch grep users. There is no basic-versus-extended split — +, ?, | and () are always operators, so \( is a literal bracket, as in grep -E. And \s never matches a newline, because by default ripgrep searches one line at a time.

Matching across lines needs -U (--multiline):

Terminal window
rg -U 'fn main\(\) \{\n\s+//'

Even then . stops at newlines. Add (?s) to the pattern, or --multiline-dotall, when you want .* to run on through them — and be aware that (?s)fn.*api will now happily match from the first fn in the file to the last api, because .* is greedy and the file is now one long line as far as it is concerned. .*? is usually what you meant.

The output flags are in the cheat sheet. A few earn a closer look, starting with one that isn’t there: editor integrations want --vimgrep, which prints path:line:column:text once per match, ready for a quickfix list.

--files deserves more fame than it has. It’s a gitignore-aware file lister, which makes it a tidy input for anything else that wants a list of your project’s real files — including fzf, which has spent years being fed worse lists than this.

-M is the minified-file fix. One matching line in a 400KB bundle is 400KB of output; -M 200 replaces it with [Omitted long matching line], and adding --max-columns-preview shows the first 200 columns rather than nothing.

--json emits a begin, match and end object for every file, with line numbers, byte offsets and the submatches themselves. It is verbose on purpose, and jq takes it apart in one line — every match as path:line:

Terminal window
rg --json fetchUser |
jq -r 'select(.type == "match") | "\(.data.path.text):\(.data.line_number)"'

For handing filenames to another command, use NUL separators so a path with a space in it doesn’t turn into two arguments:

Terminal window
rg -l0 TODO | xargs -0 wc -l

ripgrep has a -r (--replace) flag, and the first thing to understand about it is that it never touches your files. It rewrites the output and nothing else. That makes it a preview tool and an extraction tool, and both are genuinely useful:

Terminal window
rg 'fn (\w+)' -r 'func $1' # preview a rename
rg -o 'user_id=(\d+)' -r '$1' app.log # pull out just the IDs

-o with -r is the combination to remember: print only the capture group, one per line, ready for sort | uniq -c. It replaces a good share of the sed and awk one-liners you’ve been copying from Stack Overflow for years.

Three details about the replacement string:

  • Capture groups are $1, $2, or $name for a named group (?P<name>...).
  • $1_v2 is not $1 followed by _v2. ripgrep reads the longest name it can, looks for a group called 1_v2, doesn’t find one, and substitutes nothing. Write ${1}_v2.
  • A literal dollar is $$. Single quotes around the replacement keep your shell’s hands off it, which you want, because the shell has its own ideas about $1.

--passthru prints every line, matching or not, which with -r gives you the whole file with the replacement applied — redirect that and you’ve edited a file, provided you write to somewhere other than the file you’re reading.

For a real in-place edit across a project, let ripgrep find the files and something else change them:

Terminal window
rg -l0 'api_key' | xargs -0 perl -pi -e 's/api_key/apiKey/g'

perl -pi -e rather than sed -i because it behaves identically on macOS and Linux, where sed -i does not. It uses Perl’s regex dialect, not ripgrep’s, which for anything beyond a literal word is worth a moment’s thought. Or install sd, which does the replacement with a syntax no one has to look up. Either way, run it on a clean working tree, so git diff is your review and git checkout . is your undo.

ripgrep reads a config file only if you tell it where one is — there is no default location, which is either admirable restraint or a missed opportunity, depending on how many machines you have. Point RIPGREP_CONFIG_PATH at it:

~/.zshrc
export RIPGREP_CONFIG_PATH="$HOME/.config/ripgrep/rc"

The file holds one argument per line, exactly as you’d type it but without shell quoting, and lines starting with # are comments:

~/.config/ripgrep/rc
# Smart case: sensitive only when the pattern has a capital
--smart-case
# Search dotfiles, but never the git object database
--hidden
--glob=!.git
# Stop minified files flooding the screen
--max-columns=200
--max-columns-preview
# Types ripgrep doesn't know about
--type-add=web:*.{html,css,js}

Flags on the command line are applied after the config file, so they win where the two clash. When the config is the problem — and one day --hidden will be why you’re seeing results you didn’t expect — --no-config ignores it for that run.

One more option worth knowing: --hyperlink-format turns filenames into clickable links in terminals that support OSC 8, opening the match in your editor at the right line. It takes a template or an alias — vscode, cursor, kitty, macvim, textmate and a few others — so --hyperlink-format=vscode in the config is all it needs, provided your terminal emulator is one of the ones that understands the escape.

The file itself belongs in your dotfiles repo, along with the line that points at it.

The exit codes follow grep’s: 0 for a match, 1 for none, 2 for an error. -q suppresses output and stops at the first match, so the idiomatic test reads the same as it does with grep:

Terminal window
if rg -q 'DEBUG = True' settings.py; then
echo 'refusing to deploy with DEBUG on' >&2
exit 1
fi

Inside an if condition a “no match” of 1 doesn’t trip set -e, which is exactly as intended and covered, with the places it isn’t, in Bash strict mode. A bare rg returning 1 on its own line will still end a strict script, so || true when nothing found is a normal outcome.

Two more habits for scripts. Output order isn’t stable, because files are searched in parallel; --sort path fixes the order at the cost of running on a single thread. And ripgrep reads standard input when it’s given no path and something is piped in, so it slots into the middle of a pipeline like any other filter:

Terminal window
kubectl logs deploy/api | rg -i 'timeout|refused'

For a stream that never ends — tail -f on a log — mind the buffering. Printing to a terminal, ripgrep flushes every line; piping into another command, it buffers in blocks, so matches arrive in bursts minutes apart. --line-buffered fixes it:

Terminal window
tail -f app.log | rg --line-buffered ERROR | rg -v healthcheck

ripgrep vs grep: six things that catch grep users

Section titled “ripgrep vs grep: six things that catch grep users”

1. -r is not recursive. Recursion is the default, and -r means --replace. It takes an argument, so rg -r TODO src doesn’t search src for TODO — it searches the current directory for src and replaces it with TODO in the output. Usually that means no output and exit code 1, which looks exactly like “nothing found”. This is the single most expensive habit to bring over from grep.

2. -uu searches .git. It includes hidden files, and your repository’s internals are hidden files. If rg -uu suddenly returns matches from hook samples and packed refs, that is why — -g '!.git' is the fix.

3. -c counts lines, not matches. A line with three matches counts once. --count-matches counts all three.

4. It isn’t POSIX grep, so don’t alias it. The flags overlap enough to feel familiar and differ enough to hurt: -r, above, is only the most dramatic example. The starter pack makes the same argument for fd and find. Type rg. It’s shorter anyway.

5. The binary skip is silent. A match inside a file ripgrep decides is binary — anything with a NUL byte in it — isn’t reported at all in a directory search. -uuu, or -a on a named file, when it matters. -z handles the compressed case: rg -z ERROR app.log.gz searches inside gzip, xz, bzip2, zstd and friends by shelling out to the matching decompressor.

6. Ignore rules are inherited from directories above. A .gitignore in a parent directory applies to everything below it, and so does your global git excludes file. When something is missing and you can’t see why, --debug will name the file responsible.

  • On somebody else’s server, use grep. It’s there, it’s POSIX, and it’s what the runbook assumes. ripgrep is a reason to enjoy your own machines, not a reason to forget the tool that’s on all of the others.
  • For finding files by name, use fd — or rg --files -g '*name*' if you only need it once. Searching contents to find a filename is the long way round.
  • For structural code search — “every call to fetch whose second argument has no signal” — a regex is the wrong instrument. ast-grep matches on syntax trees and knows what a function call is, which no amount of \s* will teach ripgrep.
  • For a whole-codebase index, your editor’s language server and git grep both have opinions. git grep in particular searches tracked files only, and can search any commit, not just the working tree — git grep TODO v1.2.0 — which ripgrep can’t.

ripgrep is at its best feeding something interactive: fzf’s live-search recipe turns it into a fuzzy code browser with a preview pane in a dozen lines. The modern CLI starter pack has the rest of the family — fd for filenames, bat for the preview, delta for the diff after the replacement. And if the aliases and the RIPGREP_CONFIG_PATH line need somewhere to live, Zsh without the bloat is the forty-line .zshrc to put them in.