For people who already live in a terminal
Six tools, one disk,
four different questions.
Every file-search tool on a Mac is an answer to a question you did not quite ask. Learn which question each one answers and the guessing stops.
1 · Mental model
There is no such thing as "searching your Mac." There are two independent choices — ask an index or walk the disk, and match a name or match the contents — and six tools scattered across the resulting grid. Pick the cell first. The command follows.
Every tool in this guide is fast at one thing and embarrassing at another, and the reason is always the same two axes.
Axis one: index or walk
An index is a database somebody built earlier by walking the disk
so you would not have to. Querying it is fast because the work already happened. That
speed is bought with three liabilities: the index has a freshness date, it has
a scope (things it was never allowed to see), and it has a schema
(questions it cannot express). mdfind and locate query
indexes.
A walk opens directories and reads entries, right now, in the order
the filesystem hands them over. It is always current, it sees exactly what your user
account is permitted to see, and it costs I/O proportional to the size of the tree.
find, fd, grep and rg walk.
It means precomputed, which is not the same thing. locate is
indexed and took 4.35 s on the machine this guide was written on,
while fd walked 39,798 files in 121 ms
(§15). An index only wins when the alternative is walking
something much bigger than the answer.
Axis two: name or contents
Matching a name means reading directory entries — cheap, because the
filesystem hands you names for free while traversing. Matching contents
means opening and reading every candidate file, which is one to three orders of magnitude
more work. mdfind is the odd one out: it is the only tool here that
searches contents from an index, because Spotlight extracted and stored the text
of your files when they were written.
The four failure modes
Every "why can't I find my file" on a Mac is one of these. Section 18 is the full symptom table; these are the shapes.
| Failure | Looks like | Root cause |
|---|---|---|
| Stale index | You saved it five minutes ago and
locate shrugs | The index predates the file. locate's
is rebuilt weekly. |
| Scope hole | The file exists, the tool is current, still nothing | The indexer was never allowed there: locate runs as
nobody; Spotlight skips volumes with indexing off and anything under a
.noindex folder. |
| Filtered out | grep -r finds it,
rg does not | rg and fd obey
.gitignore, your global git excludes file, and hide dotfiles by default. |
| Wrong dialect | The pattern you copied off the internet returns nothing, or an error | Four regex dialects are in play on one machine
(§8), and macOS grep is BSD, not GNU. |
"Which search tool is best?" has no answer. The useful question is "is my answer already in an index, and am I matching a name or a body of text?" Two yes/no answers pick the tool for you, and §16 turns that into a flowchart you can follow without thinking.
Sources: mdfind(1) · locate(1) · ripgrep user guide
2 · What the shell does before the tool runs
Half of all file-search bugs are not search bugs. The shell rewrites your command line before the tool ever sees it, and the two languages involved — shell globs and regular expressions — use overlapping punctuation to mean opposite things.
Self-assessment
Answer these before reading on. If all five are instant, skim to §3; if any makes you pause, this section is the floor the rest of the guide stands on.
1. In find . -name *.js, who expands the *?
The shell does, before find starts — and that is a bug. If the current
directory contains two .js files, zsh hands find the words
client.js retry.js and you get:
find: retry.js: unknown primary or operator
If it contains none, zsh refuses to run the command at all:
zsh: no matches found: *.js
Quote it — find . -name '*.js' — and find receives the
literal *.js and does its own matching.
2. Does grep 'a.b' match the line axb?
Yes. In a regex, . is "any character". Verified:
printf 'a.b\naxb\n' | grep 'a.b'
a.b
axb
To match a literal dot, escape it (a\.b) or drop to fixed strings
(grep -F 'a.b').
3. What does rg '*.js' do?
It fails, because rg takes a regex, not a glob, and a regex may
not begin with a repetition operator:
rg: regex parse error:
(?:*.js)
^
error: repetition operator missing expression
The glob goes in -g: rg -g '*.js' PATTERN.
4. What is an extended attribute, and why does it matter here?
macOS hangs arbitrary key/value data off a file beside its contents. The @
in ls -l output marks a file that has some:
ls -li docs/notes.md
465192060 -rw-r--r--@ 1 you wheel 52 Sep 10 12:54 docs/notes.md
xattr -l docs/notes.md
com.apple.provenance:
It matters because Finder tags, quarantine flags and "Where from" URLs live there, and
Spotlight indexes them as kMDItem* attributes. grep will never
see them; mdfind searches them (§13).
5. What is the difference between -name and -path?
-name matches the last component only, so -name '*.js' never
matches a directory prefix. -path matches the whole path as printed, which is
why pruning looks like -not -path './node_modules/*'.
Globs and regexes are different languages
They share *, ? and […] and agree on almost
none of them. Knowing which language a given argument is written in is the single
highest-leverage fact in this guide.
| Symbol | As a shell glob | As a regex |
|---|---|---|
* | any run of characters, including none | "zero or more of the previous item" — meaningless at the start of a pattern |
? | exactly one character | "the previous item is optional"
(ERE); a literal ? in BRE |
. | a literal dot | any single character |
[a-z] | one character in the set | one character in the set — the one place they agree |
^ $ | literal characters | start / end anchors |
| Matches against | the whole filename, implicitly anchored both ends | any substring, unless you anchor it yourself |
That last row is the one that bites. find -name '*.txt' is anchored, so it
only matches names ending in .txt. locate '.pb.go' is a
substring match, so it also returns annotations.pb.go.meta. Same intent,
different answer — measured in §15.
| Tool | Its pattern argument is… | Anchored? | Case |
|---|---|---|---|
find -name | a glob | yes, whole basename | sensitive (-iname to relax) |
find -path | a glob | yes, whole path | sensitive |
fd | a regex on the path | no | smart — sensitive only if you type a capital |
fd -g | a glob | yes, whole basename | smart |
locate | a substring, or a glob if it contains metacharacters | no | sensitive (-i to relax) |
grep | a BRE (-E for ERE, -F for literal) | no | sensitive (-i to relax) |
rg | a Rust regex | no | sensitive — -S to make it smart |
rg -g | a gitignore-style glob | path-relative | sensitive |
mdfind | a Spotlight query — words, not characters | token-wise | insensitive by default |
zsh: no matches found. bash instead passes the pattern
through unchanged, which is why a command copied from a bash-era blog post can behave
differently in your terminal.A file is a name in a directory pointing at an inode. ls -li shows the
inode number. This is why find -samefile and hard links exist, and why
"the same file" can appear at two paths and be found twice by
locate. fd and rg do not follow symlinks by
default; find follows them only with -L.
Sources: find(1) · POSIX shell — pattern matching
3 · Install & verify
Four of the six ship with macOS. The two that do not are the two you will use most. Before anything else, find out which binary your shell actually runs — on a machine with Homebrew, MacPorts and a dotfile collection, that is a real question with a surprising answer.
What is already there
| Tool | Path | Ships with macOS? | Implementation |
|---|---|---|---|
find | /usr/bin/find | yes | BSD find — not GNU findutils |
grep, egrep, fgrep | /usr/bin/grep | yes | BSD grep 2.6.0-FreeBSD (hard links to one binary) |
locate | /usr/bin/locate | yes | BSD locate; database job ships disabled |
mdfind, mdls, mdutil, mdimport | /usr/bin/ | yes | Spotlight client tools |
fd | Homebrew | no | brew install fd |
rg | Homebrew | no | brew install ripgrep |
ugrep, ggrep | Homebrew or MacPorts | no | optional but recommended — see §10 |
# the two you have to add
brew install fd ripgrep
# optional, but this guide uses it in §14
brew install fzf
# worth adding once you have read §10: the fastest grep here, and the portable one
brew install ugrep grep # "grep" installs GNU grep as ggrep
Homebrew installs to /opt/homebrew on Apple silicon and
/usr/local on Intel. This guide was verified on an Intel Mac, so transcripts
show /usr/local/bin/fd. Do not hard-code either path in scripts — call the
bare name and let PATH resolve it, or ask command -v fd.
Verify — and find out what grep really is
sw_vers
ProductName: macOS
ProductVersion: 15.6
BuildVersion: 24G84
fd --version → fd 10.5.0
rg --version → ripgrep 15.2.0
features:+pcre2
PCRE2 10.45 is available (JIT is available)
/usr/bin/grep --version
grep (BSD grep, GNU compatible) 2.6.0-FreeBSD
Now the important one. which grep lies if there is a shell alias or
function in the way; type tells the truth:
type grep egrep fgrep
grep is an alias for grep --color=auto --exclude-dir={.bzr,CVS,.git,.hg,.svn,.idea,.tox,.venv,venv}
egrep is an alias for grep -E
fgrep is an alias for grep -F
The alias above — shipped by Oh My Zsh and countless dotfile repos — silently adds
--exclude-dir to every grep you run interactively. That is
usually what you want and occasionally the reason a file "does not exist." Bypass it for
one command with a leading backslash: \grep …, or call
/usr/bin/grep outright. Aliases also do not apply inside scripts, so a
script and your prompt can disagree about what grep means.
zsh expands aliases recursively on the first word, so egrep foo becomes
grep --color=auto --exclude-dir={…} -E foo.
- Create the tree:
export SB=$(mktemp -d) && cd "$SB" mkdir -p src/{api,web} docs build node_modules/left-pad logs "with space dir" printf 'const TIMEOUT = 30;\nfunction connect(host) {\n return fetch(host);\n}\n' > src/api/client.js printf 'const timeout = 5000;\nexport function retry(fn) { return fn(); }\n' > src/api/retry.js printf '<h1>Hello</h1>\n<p>TIMEOUT is not set here.</p>\n' > src/web/index.html printf 'body { color: red; }\n' > src/web/style.css printf '# Notes\nSet TIMEOUT in the config.\ntimeout matters.\n' > docs/notes.md printf 'draft\n' > docs/draft.txt printf 'compiled junk TIMEOUT\n' > build/bundle.js printf 'module.exports = 1;\n' > node_modules/left-pad/index.js printf 'ERROR timeout after 30s\nINFO ok\nERROR TIMEOUT again\n' > logs/app.log printf '.hidden secret TIMEOUT\n' > .env printf 'build/\nnode_modules/\n*.log\n' > .gitignore printf 'spaced TIMEOUT\n' > "with space dir/a file.txt" : > src/empty.js ln -s ../docs/notes.md src/link-to-notes.md touch -t 202501011200 docs/draft.txt git init -q . - Confirm the shape:
Expect: exactly 13 paths, in this order — ./.env ./.gitignore ./build/bundle.js ./docs/draft.txt ./docs/notes.md ./logs/app.log ./node_modules/left-pad/index.js ./src/api/client.js ./src/api/retry.js ./src/empty.js ./src/web/index.html ./src/web/style.css ./with space dir/a file.txt (src/link-to-notes.md is missing because it is a symlink, and -type f means "regular file". That is the first of many quiet exclusions in this guide.)find . -type f -not -path './.git/*' | sort - Note what makes it interesting: a
.gitignore, a real git repo, a dotfile, a filename with spaces, an empty file, a symlink, and a file dated 2025. Every one of those trips at least one tool later on.
echo $SB prints a path
under /var/folders/… or /tmp. Keep this shell open — later labs
reuse $SB. Everything here is disposable; rm -rf "$SB" when done.mktemp -d lands under /var/folders or /tmp, and
both are excluded from the Spotlight index and from locate's database by
design. The mdfind labs in §12 therefore build a
second sandbox inside your home directory. That is not an accident of this
guide — it is the scope axis from §1 showing up the first
time you try to use it.
Sources: fd — installation · ripgrep — installation · grep(1) on macOS
4 · find — the baseline everything else is measured against
find is not a search command. It is a tiny declarative
language for walking a directory tree and running a boolean expression against every
entry it meets. Once you see the expression, the syntax that looks arbitrary becomes
almost inevitable.
The shape is always the same:
find [options] where what-to-match what-to-do
find . -type f -name '*.js' -print reads as: start at .;
for every entry, test is a regular file AND basename matches
*.js AND print it. Predicates are ANDed by juxtaposition,
they short-circuit left to right, and -print is a predicate too — it
"succeeds" and has the side effect of printing. That is why
find . -name '*.js' -print -o -print does not double up: the first
-print already returned true.
The predicates that earn their keep
| Predicate | Means | Note |
|---|---|---|
-name '*.js' | basename glob | anchored to the whole basename; -iname for case-insensitive |
-path './build/*' | full-path glob | matched against the path as printed, so the leading ./ counts |
-type f d l | regular file / directory / symlink | -type l is how you list symlinks without following them |
-size -2 | smaller than 2 blocks (512 B) | suffix it: -size +10M, -size -1k |
-mtime +30 | modified more than 30 days ago | -mmin for minutes; + older, - newer |
-newer FILE | modified after FILE was | the cheapest "since the last build" filter there is |
-prune | do not descend into this | faster than -not -path: it never enters the directory |
-maxdepth N | stop at depth N | on BSD find it must come before other predicates |
-exec … {} + | batch the matches into one command | see below — this is the important one |
# everything modified in the last 30 days is *not* -mtime +30
find . -type f -mtime +30
./docs/draft.txt
# "changed since the last build" without a timestamp file of your own
find . -type f -newer docs/draft.txt -name '*.md'
./docs/notes.md
# BSD find has -s: walk in sorted order (GNU findutils does not)
find -s . -maxdepth 1 -name '[a-z]*'
./build
./docs
./logs
./node_modules
./src
./with space dir
-exec {} + versus | xargs
Three ways to do something with the matches, in ascending order of how much you should like them:
| Form | Processes | Safe with spaces? | Verdict |
|---|---|---|---|
-exec cmd {} \; | one per match | yes | correct but slow — fine for a handful |
-exec cmd {} + | batched, like xargs | yes | the default choice |
| xargs cmd | batched | no | broken on any path with a space |
-print0 | xargs -0 cmd | batched | yes | use when you need a pipeline stage in between |
find . -name '*.js' -not -path './.git/*' -exec wc -l {} +
1 ./node_modules/left-pad/index.js
1 ./build/bundle.js
0 ./src/empty.js
4 ./src/api/client.js
2 ./src/api/retry.js
8 total
Now watch the naive pipeline meet the directory called with space dir:
find . -name '*.txt' | xargs wc -c
wc: ./with: open: No such file or directory
wc: space: open: No such file or directory
wc: dir/a: open: No such file or directory
wc: file.txt: open: No such file or directory
6 ./docs/draft.txt
6 total
# the NUL-separated form is correct
find . -name '*.txt' -print0 | xargs -0 wc -c
15 ./with space dir/a file.txt
6 ./docs/draft.txt
21 total
A newline is a legal character in a macOS filename. So is a space, a quote and a tab.
The only separator that cannot appear inside a path is the NUL byte. Any pipeline
that moves filenames between processes must use it:
find -print0 / fd -0 / rg --null /
mdfind -0 on one side, xargs -0 on the other. This is not
pedantry — the failure above is silent data loss in a script that deletes things.
BSD find is not GNU find
Commands copied from Linux answers fail here in specific, recognisable ways.
| You wanted | GNU findutils | macOS (BSD) find |
|---|---|---|
| Custom output format | -printf '%p\n' | does not exist — find: -printf: unknown primary or operator |
| Extended regex | -regextype posix-extended -regex … | find -E . -regex … (the -E goes before the path) |
| Sorted output | not available | find -s . |
| Depth limit | anywhere in the expression | -maxdepth / -mindepth must precede other predicates |
| Delete matches | -delete | -delete exists, and is just as dangerous |
| GNU behaviour anyway | — | brew install findutils → gfind |
# BSD extended-regex form — note -E before the path
find -E . -regex '.*/(client|retry)\.js'
./src/api/client.js
./src/api/retry.js
-delete deserves a rehearsal
Run the expression with -print first, read the list, then swap in
-delete. find evaluates left to right, so
find . -delete -name '*.tmp' deletes everything it meets before it ever
tests the name. There is no undo and no Trash.
- Ask for JavaScript files:
Expect: 5 paths, including node_modules/left-pad/index.js and build/bundle.js — find has no opinion about your .gitignore.cd "$SB" && find . -name '*.js' - Prune the noise two ways and compare:
Expect: both print the same 3 files. The second never opens those directories at all — on a real tree with a 300 MB node_modules that is the whole difference.find . -name '*.js' -not -path './node_modules/*' -not -path './build/*' find . \( -name node_modules -o -name build \) -prune -o -name '*.js' -print - Break it on purpose, then fix it:
Expect: find: retry.js: unknown primary or operator — the shell expanded the glob into two words. Quoting it fixes it.cd src/api && find . -name *.js ; cd "$SB" - Prove the NUL problem to yourself:
Expect: four "No such file or directory" errors from the first, a clean two-file total from the second.find . -name '*.txt' | xargs wc -c find . -name '*.txt' -print0 | xargs -0 wc -c
-prune beats -not -path and why -print0 is not
optional in a script.Sources: find(1) on macOS · xargs(1) · POSIX find
5 · fd — the same walk, with opinions
fd is not a faster find. It is a
differently scoped find: it walks in parallel, matches a regex
against the path instead of a glob against the basename, and — the part that surprises
people — deliberately refuses to show you files you almost certainly did not mean.
Run both on the sandbox and the difference is immediate:
find . -name '*.js' fd -e js
./node_modules/left-pad/index.js src/api/client.js
./build/bundle.js src/api/retry.js
./src/empty.js src/empty.js
./src/api/client.js
./src/api/retry.js
5 results 3 results
fd dropped build/bundle.js and
node_modules/left-pad/index.js because the sandbox's .gitignore
lists them. That is a feature roughly 95% of the time and a trap the other 5%. Turn it
off explicitly:
fd -e js --no-ignore --hidden
build/bundle.js
node_modules/left-pad/index.js
src/api/client.js
src/api/retry.js
src/empty.js
What fd hides, and the flags that unhide it
| Hidden by default | Reveal with |
|---|---|
Anything matched by .gitignore, .ignore, .fdignore, or your global git excludes file | -I / --no-ignore |
| Dotfiles and dot-directories | -H / --hidden |
| Both at once | -u — fd --help calls it "an alias for --hidden --no-ignore" |
Only the git-derived rules (.gitignore, .git/info/exclude, and your global gitignore) | --no-ignore-vcs |
| Symlink targets (it lists the link, does not descend) | -L / --follow |
Smart case — and why rg does the opposite
fd is case-insensitive until your pattern contains an uppercase letter,
at which point it becomes case-sensitive. This is genuinely convenient and genuinely
surprising the first time it bites:
fd -H -I 'ENV'
(nothing — the capitals made it case-sensitive, and the file is .env)
fd -H -I 'env'
.env
They do not. fd is smart-case by default; rg is
case-sensitive by default and needs -S to become smart. Two
tools with near-identical filtering philosophies disagree on this one axis, and it is the
most common reason a rg search comes back emptier than expected
(§9).
Everyday fd
# type filter: f file · d directory · l symlink · x executable · e empty
fd -t d
docs/
logs/
src/
src/api/
src/web/
with space dir/
# extension filter beats writing a regex for it
fd -t f -e md
docs/notes.md
# time filters, in plain English
fd --changed-within 1h -t f
fd --changed-before 2weeks -t f
# literal string, no regex parsing at all
fd -F 'a file'
with space dir/a file.txt
# ls -l style detail, without a second command
fd -e md -l
-rw-r--r-- 1 you wheel 52 Sep 10 12:54 ./docs/notes.md
lrwxr-xr-x 1 you wheel 16 Sep 10 12:54 ./src/link-to-notes.md -> ../docs/notes.md
-x and -X: exec without xargs
fd folds -exec and xargs into two flags, and
handles the quoting for you:
fd -e js -x wc -l {} # one process per file, in parallel
fd -e js -X wc -l # one process for all files (note the total line)
2 ./src/api/retry.js
4 ./src/api/client.js
0 ./src/empty.js
6 total
Placeholders inside -x: {} the path, {/} the
basename, {//} the parent, {.} the path without its extension,
{/.} the basename without its extension.
fd's synopsis is fd [OPTIONS] [pattern] [path]…. A single
positional argument is the pattern, not the path — so
fd -e pdf ~ does not search your home directory. It prints nothing to stdout
and explains itself on stderr:
fd -e pdf ~
[fd error]: The search pattern '/Users/you' contains a path-separation
character and will not lead to any search results.
If you want to search for all files inside the '/Users/you' directory,
use a match-all pattern:
fd . '/Users/you'
Write fd . ~ or fd PATTERN ~. In a pipeline with stderr
redirected away, the silence looks exactly like "no matches" — which is how this one costs
people an hour.
fd walks with a thread pool, so results arrive in whatever order the
threads finish. Small trees often look sorted by luck. If a script depends on the order,
pipe through sort, or pass --threads 1. rg has the
same property and offers --sort path for it; fd has no
equivalent flag.
Expect: 3 results, all under src/.cd "$SB" && fd -e js- Now count what a plain walk sees:
Expect: 5.find . -name '*.js' -not -path './.git/*' | wc -l - Ask
fdto explain the gap by turning the filters off one at a time:
Expect: --no-ignore restores both files; --hidden restores neither. The exclusion was .gitignore, not dotfile-hiding.fd -e js --no-ignore | wc -l # → 5 fd -e js --hidden | wc -l # → 3 - Ask git the same question directly — this is the diagnostic to remember:
Expect: .gitignore:1:build/ build/bundle.js — file, line number, and the rule that matched.git check-ignore -v build/bundle.js
fd or rg output, your first move is
git check-ignore -v <path>, not -uu.Sources: sharkdp/fd — README · fd — command-line options
6 · locate — a weekly photograph of your filenames
locate does not search your disk. It greps a compressed
text file that a launchd job produced last Saturday morning by walking the disk
as the user nobody. Every one of that sentence's clauses is a
limitation you will meet, and together they explain everything locate gets
right and wrong.
The lifecycle
nobody, and it happens weekly. Nothing about locate is
incremental.The job ships disabled
Apple installs the launchd plist with Disabled set to true. On a Mac
where nobody has turned it on, the database file simply is not there, and
locate tells you so rather than returning an empty result:
locate anything
locate: `/var/db/locate.database': No such file or directory
Enable the job once and it maintains itself:
# is it on?
sudo launchctl print-disabled system | grep locate
"com.apple.locate" => enabled
# turn it on (also triggers a first build via the plist's KeepAlive PathState)
sudo launchctl load -w /System/Library/LaunchDaemons/com.apple.locate.plist
sudo /usr/libexec/locate.updatedb traverses every native filesystem on the
machine. It is safe and interruptible, but it is not a thing to fire off casually on a
laptop on battery, and it needs sudo. If you need a fresh answer now,
the right tool is fd or mdfind, not a rebuild.
Reading the database's own vital signs
locate -S
Database: /var/db/locate.database
Compression: Front: 12.01%, Bigram: 67.34%, Total: 9.05%
Filenames: 11281033, Characters: 1256828120, Database size: 113744501
# how stale is it, really?
stat -f '%Sm owner=%Su' -t '%Y-%m-%d %H:%M' /var/db/locate.database
2026-09-05 03:35 owner=nobody
That timestamp is the single most useful thing about locate. Anything
created after it is invisible, and the answer is never "wait a moment and retry."
The nobody boundary, measured
The database only contains paths that the unprivileged user nobody could
traverse. Your home directory is mode 755, so it is in. ~/Documents and
~/Library are mode 700, so they are not — at all:
ls -ld ~ ~/Documents ~/Library
drwxr-xr-x@ 782 you staff /Users/you
drwx------@ 97 you staff /Users/you/Documents
drwx------@ 145 you staff /Users/you/Library
locate -c '/Users/you/guides/' → 337
locate -c '/Users/you/Documents/' → 0
locate -c '/Users/you/Library/' → 0
locate indexes my whole Mac"
It indexes what an anonymous user can walk. Your Documents, your Desktop if you have
tightened it, your Mail, your Photos library, every app's Application Support folder —
none of it is there, and no amount of rebuilding will put it there. That is a deliberate
privacy property inherited from FreeBSD, not a bug. mdfind has no such
limitation, because Spotlight indexes per-volume as root and enforces access at query
time.
What else is excluded
Read the rules yourself — they are plain shell variables near the top of
/usr/libexec/locate.updatedb:
| Variable | Value on macOS 15.6 | Consequence |
|---|---|---|
SEARCHPATHS | / | one pass from the root |
FILESYSTEMS | hfs ufs apfs | exFAT, FAT32, NTFS, SMB and NFS mounts are skipped entirely |
PRUNEPATHS | /private/tmp /private/var/folders /private/var/tmp */Backups.backupdb | your mktemp -d sandbox and Time Machine backups are invisible |
| firmlinks | read from /usr/share/firmlinks | prevents every path appearing twice under /System/Volumes/Data |
LOCATE_CONFIG | /etc/locate.rc | override any of the above without editing the script |
Using it well
# plain substring — matches anywhere in the full path
locate -l 5 mdfind
locate: [show only 5 lines]
/Applications/Xcode.app/…/man1/mdfind.1
/Library/Developer/CommandLineTools/SDKs/MacOSX15.5.sdk/…/mdfind.1
/Users/you/man/mdfind.pdf
# contains a metacharacter → treated as a glob against the whole path
locate '/usr/bin/md*'
/usr/bin/mddiagnose
/usr/bin/mdfind
/usr/bin/mdimport
/usr/bin/mdls
/usr/bin/mdutil
# count instead of listing; -i for case-insensitive; -0 for xargs safety
locate -c 'bin/grep'
locate -0 '*.pdf' | xargs -0 ls -lh
locate '.pb.go' returned 3,952 paths where
find -name '*.pb.go' found 3,888 under the same tree. The extras are real
files whose names merely contain the string:
gogo.pb.golden and annotations.pb.go.meta. Neither tool is wrong;
they were asked different questions. Anchor with locate '*.pb.go' if you
meant the glob.
- Date the index and count what it holds:
Expect: a Saturday around 03:15, up to seven days ago. If the file does not exist, the launchd job has never run — see "the job ships disabled" above.locate -S stat -f '%Sm' -t '%Y-%m-%d %H:%M' /var/db/locate.database - Create a file and watch
locatenot care:
Expect: locate prints nothing; fd prints the file immediately. This is the freshness axis of §1, in two commands.touch ~/locate-staleness-probe.txt locate locate-staleness-probe fd -H locate-staleness-probe ~ -d 1 - Probe the privacy boundary:
Expect: 0 for Documents (mode 700), a large number for the home directory itself (mode 755).locate -c "$HOME/Documents/" locate -c "$HOME/" - Clean up:
rm ~/locate-staleness-probe.txt
locate is still the right answer
When you want a system file whose name you half-remember and you do not know which of
/usr, /opt, /Library or an Xcode SDK it lives in.
Walking all of those with fd costs minutes; locate answers in
seconds from a snapshot that is plenty fresh for files installed by a package manager.
For anything you wrote yourself this week, it is the wrong tool.
Sources:
locate(1) ·
locate.updatedb(8) ·
/usr/libexec/locate.updatedb and
/System/Library/LaunchDaemons/com.apple.locate.plist on the machine itself
7 · grep and egrep — the BSD one
The grep on your Mac is not the grep that
almost every tutorial on the internet was written against. It is BSD grep, inherited from
FreeBSD, and the differences are small in number and large in consequence.
/usr/bin/grep --version
grep (BSD grep, GNU compatible) 2.6.0-FreeBSD
"GNU compatible" is an aspiration, not a guarantee. Two of the gaps are load-bearing.
Gap 1: there is no -P
printf 'abc123\n' | grep -P '\d+'
grep: invalid option -- P
usage: grep [-abcdDEFGHhIiJLlMmnOopqRSsUVvwXxZz] [-A num] [-B num] [-C[num]]
[-e pattern] [-f file] [--binary-files=value] [--color=when]
[--context[=num]] [--directories=action] [--label] [--line-buffered]
[--null] [pattern] [file ...]
# exit status 2 — "an error occurred", not "no match"
If you need Perl-compatible regex from a grep-shaped tool on macOS the answer is
rg -P (§9), ugrep -P, ggrep -P
or pcre2grep — all four verified working here (§10). It
is never grep.
printf 'foobar\nfoobaz\n' | ggrep -P 'foo(?=bar)' → foobar
printf 'foobar\nfoobaz\n' | ugrep -P 'foo(?=bar)' → foobar
Gap 2: -r and -R are the same flag
On GNU grep, -r recurses without following symlinked directories and
-R follows them. On macOS the man page lists them on one line —
-R, -r, --recursive — and behaviour confirms it: neither descends into a
symlinked directory. Both sides, measured against the same symlinked directory:
# src/linkdir is a symlink to a sibling directory holding one matching file
grep -r 'TIMEOUT in linked' src/ → (nothing, rc=1)
grep -R 'TIMEOUT in linked' src/ → (nothing, rc=1) ← BSD: same flag
ggrep -r 'TIMEOUT in linked' src/ → (nothing, rc=1)
ggrep -R 'TIMEOUT in linked' src/ → src/linkdir/deep.txt:TIMEOUT in linked dir
A script that relies on -R following links searches less than its author
intended the moment it runs on macOS — silently, with exit status 1, which looks exactly
like "no matches".
The macOS grep man page still says -R makes grep "behave as
rgrep", but there is no /usr/bin/rgrep on macOS 15.6.
Meanwhile egrep and fgrep are hard links to a single
153,760-byte binary (same inode, link count 4) — and grep itself is a
separate inode of the same size. Checking with ls -li takes two seconds and
beats repeating what a 2004-era man page implies.
Gap 3 (in your favour): egrep is not deprecated here
GNU grep from 3.8 onwards warns that egrep is obsolescent every time you
run it. BSD grep does not. Both halves measured on this machine — first BSD, silent and
exit 0:
echo foo | /usr/bin/egrep 'o+'
foo
# no warning, exit status 0
…then GNU grep 3.12, installed alongside as ggrep, on the same input:
echo foo | gegrep 'o+'
gegrep: warning: gegrep is obsolescent; using ggrep -E
foo
# warning on stderr, match still printed, exit status 0
So egrep is perfectly safe on macOS today — and the same script starts
emitting warnings the moment it runs on a modern Linux box or through
ggrep. Write grep -E and the question never arises.
The flags worth memorising
| Flag | Effect | Notes |
|---|---|---|
-E / -F / -G | ERE / fixed strings / BRE | BRE is the default; -F is faster and safer for literals |
-r | recurse into directories | identical to -R on macOS; does not follow symlinked dirs |
-n / -H / -h | line numbers / force filename / suppress filename | -H matters when grepping exactly one file in a script |
-l / -L | names of files with / without matches | stops reading each file at the first hit — much faster |
-c | count matching lines | not matches; two hits on one line count once |
-o | print only the matched part | the entire reason to use grep in a pipeline |
-A/-B/-C n | context after / before / around | -C1 is usually enough to read a hit |
-i / -w / -x | ignore case / whole word / whole line | -w beats writing \b…\b by hand |
-v | invert the match | composes: grep -v ERROR | grep -c WARN |
-q | silent; exit status is the answer | the correct form inside if |
-e PAT / -f FILE | pattern that starts with - / patterns from a file | -f takes one pattern per line |
--include / --exclude / --exclude-dir | glob filters during recursion | the man page says --exclude-dir needs -R; in practice -r works too |
# the ones you will actually type
grep -rn 'TIMEOUT' . --exclude-dir=.git
./with space dir/a file.txt:1:spaced TIMEOUT
./docs/notes.md:2:Set TIMEOUT in the config.
./logs/app.log:3:ERROR TIMEOUT again
./.env:1:.hidden secret TIMEOUT
./build/bundle.js:1:compiled junk TIMEOUT
./src/web/index.html:2:<p>TIMEOUT is not set here.</p>
./src/api/client.js:1:const TIMEOUT = 30;
# only the matched text — grep as an extractor
grep -o 'TIME[A-Z]*' logs/app.log
TIMEOUT
# one line of context each side
grep -C1 'ERROR TIMEOUT' logs/app.log
INFO ok
ERROR TIMEOUT again
Exit status is the interface
| Status | Means | Verified |
|---|---|---|
0 | at least one line matched | printf 'a\n' | grep a |
1 | no lines matched — not an error | printf 'a\n' | grep b |
2 | an actual error: bad pattern, unreadable file, unknown flag | grep a /nonexistent |
set -e and grep -q do not get along
Under set -e, a grep that finds nothing exits 1 and kills your
script. Write if grep -q PAT file; then … fi, or
grep -q PAT file || true. This is the single most common shell-script bug
involving grep, and it only shows up when the input doesn't match — i.e. in
production.
Note also that all seven files above include .env, build/ and
logs/. grep -r has never heard of .gitignore. That
is the whole difference between §7 and §9, and sometimes it is the
reason to reach for the old tool.
- Count files versus count lines and notice they differ:
Expect: 7 files, then docs/notes.md:2 — one file, two matching lines.cd "$SB" grep -rl 'TIMEOUT' . --exclude-dir=.git | wc -l grep -rc 'timeout' -i docs/notes.md - Use the exit status, not the output:
Expect: present, then rc=1.if grep -q 'TIMEOUT' docs/notes.md; then echo present; else echo absent; fi grep -q 'NOPE' docs/notes.md; echo "rc=$?" - Watch
-Fsave you from a regex you did not mean to write:
Expect: two lines from the first, one from the second.printf 'a.b\naxb\n' | grep 'a.b' printf 'a.b\naxb\n' | grep -F 'a.b' - Confirm your machine's grep really is BSD:
Expect: grep: invalid option -- P, then rc=2.grep -P '\d' /etc/hosts; echo "rc=$?"
grep before running it, and you never use -P on macOS by
reflex again.Sources: grep(1) on macOS · GNU grep manual (for the contrast) · POSIX grep
8 · The four regex dialects on one Mac
A regular expression is not a language. It is four languages that share an alphabet, and your Mac has all four installed. A pattern that works in one and fails in another is not a bug — it is a translation error, and the table below is the dictionary.
| Dialect | Reached by | Engine | Shape |
|---|---|---|---|
| BRE — POSIX basic | grep, grep -G, sed | system regex(3) | quantifiers and groups must be escaped to be operators |
| ERE — POSIX extended | grep -E, egrep, find -E, awk | system regex(3) | escape a metacharacter to make it literal — the modern convention |
| Rust regex | rg, fd | regex crate (finite automata) | ERE-like plus Perl classes, minus anything needing backtracking |
| PCRE2 | rg -P, ugrep -P, ggrep -P, pcre2grep | PCRE2 10.45, JIT enabled | the full Perl vocabulary, including lookaround |
The dialect is a property of the command, not of the machine. On a Mac with the §10 tools installed:
grep→ BRE ·grep -E→ ERE · no PCRE2 at allggrep→ BRE ·ggrep -E→ ERE ·ggrep -P→ PCRE2ugrep→ ERE by default (not BRE — pass-Gfor that) ·ugrep -P→ PCRE2rg→ Rust regex ·rg -P→ PCRE2fd→ Rust regex, matched against the whole path
That ugrep line is the trap: ugrep 'a+' behaves like
grep -E 'a+', so a pattern that was safely literal under grep
becomes a quantifier.
The measured truth table
Every cell below was produced by running the pattern against the same eight-line
input on macOS 15.6 with grep 2.6.0-FreeBSD and ripgrep 15.2.0.
Two of them contradict what people confidently say about macOS.
| Pattern | grepBRE | grep -EERE |
rgRust | rg -PPCRE2 |
|---|---|---|---|---|
a+ | literal + | quantifier | quantifier | quantifier |
a\+ | quantifier | literal + | literal + | literal + |
cat|dog | literal | | alternation | alternation | alternation |
cat\|dog | alternation | literal | | literal | | literal | |
a{3} | literal braces | interval | interval | interval |
a\{3\} | interval | literal braces | literal braces | literal braces |
\d | works | works | works | works |
\w, \s, \b | works | works | works | works |
[[:digit:]] | works | works | works | works |
\(ab\)\1 | backreference | error: invalid backreference number | parse error | PCRE2 compile error |
(ab)\1 | error: invalid backreference number | backreference | parse error | backreference |
foo(?=bar) | no match, silently | error: repetition-operator operand invalid | parse error, with advice | lookahead |
\d, install GNU grep"
This is repeated everywhere and it is wrong on macOS 15.6.
printf 'abc123\n' | grep -E '\d' prints abc123. So do
\w, \s and \b, in BRE and ERE. What BSD
grep genuinely cannot do is -P — and lookaround, which is a different
complaint entirely.
The portable spelling is still [[:digit:]]. POSIX does not require
\d, so a script relying on it is relying on your libc, not on a standard.
rg refuses lookaround (and why that is a feature)
The Rust regex crate documents it plainly: the syntax "lacks several
features that are not known how to implement efficiently. This includes, but is not
limited to, look-around and backreferences." In exchange, "all regex searches in this
crate have worst case O(m * n) time complexity." A backtracking engine can
be made to hang for minutes on a 30-character pattern; this one cannot. When you pass
-P, you are opting back into that risk deliberately.
ripgrep even tells you the escape hatch in the error message:
rg: regex parse error:
(?:foo(?=bar))
^^^
error: look-around, including look-ahead and look-behind, is not supported
Consider enabling PCRE2 with the --pcre2 flag, which can handle backreferences
and look-around.
Try it
Writing patterns that survive the trip
| Goal | Portable spelling | Why |
|---|---|---|
| A digit | [[:digit:]] or [0-9] | POSIX classes work in all four dialects |
| One or more | grep -E and write + | never write BRE by choice; -E costs nothing |
| A literal string | grep -F / rg -F | no dialect at all, and faster |
| A whole word | -w | works identically in grep and rg; no \b needed |
| Case-insensitive | -i | an inline (?i) works in rg only |
| Lookahead / backreference | rg -P | the only PCRE2 on a stock-plus-Homebrew Mac |
- Set up an input you can hold in your head:
export IN='aaa\na+b\ncat\ndog\nabab\nfoobar\n' - Watch
+flip meaning:
Expect: the first prints only a+b (literal plus); the second prints aaa, a+b, cat, abab, foobar — every line containing at least one "a".printf "$IN" | grep 'a+' printf "$IN" | grep -E 'a+' - Confirm the
\dmyth is a myth on your machine:
Expect: abc123 from both. If your macOS version prints nothing, that is worth knowing — the portable form [[:digit:]] always works.printf 'abc123\nxyz\n' | grep -E '\d+' printf 'abc123\nxyz\n' | grep '\d' - Meet all three failure messages:
Expect: grep: repetition-operator operand invalid · a multi-line rg parse error that names --pcre2 · then foobar.printf "$IN" | grep -E 'foo(?=bar)' printf "$IN" | rg 'foo(?=bar)' printf "$IN" | rg -P 'foo(?=bar)' - Backreferences swap sides:
Expect: abab, abab, then a parse error saying backreferences are not supported.printf "$IN" | grep '\(ab\)\1' printf "$IN" | grep -E '(ab)\1' printf "$IN" | rg '(ab)\1'
Sources:
Rust regex crate — syntax and its deliberate omissions ·
POSIX chapter 9 — BRE and ERE ·
PCRE2 syntax summary ·
re_format(7) on macOS
9 · ripgrep — the same walk, done properly
rg is faster than grep -r for two reasons, and
only one of them is speed. It searches in parallel with a vectorised literal scanner —
and it refuses to search most of the files grep would have opened. On the
benchmark in §15 it was 20× faster than BSD
grep -r, and part of that margin is work it declined to do. It is
not the fastest tool in that table, though — see §10.
The five filters, in the order they apply
Default behaviour, demonstrated
rg TIMEOUT
with space dir/a file.txt:spaced TIMEOUT
src/web/index.html:<p>TIMEOUT is not set here.</p>
src/api/client.js:const TIMEOUT = 30;
docs/notes.md:Set TIMEOUT in the config.
rg -uu TIMEOUT
.env:.hidden secret TIMEOUT
src/api/client.js:const TIMEOUT = 30;
logs/app.log:ERROR TIMEOUT again
src/web/index.html:<p>TIMEOUT is not set here.</p>
with space dir/a file.txt:spaced TIMEOUT
docs/notes.md:Set TIMEOUT in the config.
build/bundle.js:compiled junk TIMEOUT
.env that ripgrep would not show, and why
rg --hidden TIMEOUT still misses .env. Turning on
--hidden defeats filter 3, but .env was removed by filter 2 —
a line in ~/.gitignore_global, pointed at by core.excludesFile,
which applies to every repository on the machine. One command tells you which rule ate
your file, in which file, on which line:
git check-ignore -v .env
/Users/you/.gitignore_global:56:.env .env
Make this your reflex. "Why doesn't ripgrep see my file" has exactly one good diagnostic and this is it.
Case sensitivity: the opposite of fd
rg timeout # case-SENSITIVE by default
src/api/retry.js:const timeout = 5000;
docs/notes.md:timeout matters.
rg -S timeout # smart case: lowercase pattern → insensitive
src/api/retry.js:const timeout = 5000;
docs/notes.md:Set TIMEOUT in the config.
docs/notes.md:timeout matters.
with space dir/a file.txt:spaced TIMEOUT
src/api/client.js:const TIMEOUT = 30;
src/web/index.html:<p>TIMEOUT is not set here.</p>
Two hits versus six, from one flag. If you want smart case permanently, put it in a
config file (below) rather than retyping -S.
The flags that change how you work
| Flag | Effect |
|---|---|
--files | list every file rg would search and stop. The fastest way to see the filters at work — and a great input to fzf. |
-g '*.md' / -g '!vendor/*' | gitignore-syntax globs, include and exclude |
-t js / -T js / --type-list | named file types; --type-add 'web:*.{html,css,js}' to define your own |
-l / --files-without-match | files with / without a hit |
-c | per-file count of matching lines |
-o and -r '$1' | print only the match; rewrite it with capture groups |
-A/-B/-C | context lines, same as grep |
--sort path | deterministic output — costs the parallelism, so use it only when you need it |
--stats | matches, files searched, bytes, seconds — the honest profiler |
--null | NUL-terminate filenames for xargs -0 |
-P | switch to PCRE2 for lookaround and backreferences |
--debug | explains its own decisions, including which ignore rule matched |
# rewrite while extracting — grep -o with capture groups
rg -uu -o -r 'LEVEL=$1' '^(ERROR|INFO)' logs/app.log
LEVEL=ERROR
LEVEL=INFO
LEVEL=ERROR
# the honest profiler
rg --stats TIMEOUT
4 matches
4 matched lines
4 files contained matches
8 files searched
171 bytes printed
275 bytes searched
0.000598 seconds spent searching
0.004693 seconds total
8 files searched out of 13 on disk. That number, not the timing, is where
most of ripgrep's advantage comes from on a real project.
Make your defaults permanent
ripgrep reads no config file unless you tell it where one is:
# ~/.zshrc
export RIPGREP_CONFIG_PATH="$HOME/.config/ripgrep/config"
# ~/.config/ripgrep/config — one argument per line, # for comments
--smart-case
--max-columns=200
--max-columns-preview
--type-add=web:*.{html,css,js,ts,tsx}
--glob=!.git/*
Once --smart-case is in your config, a command you paste into a colleague's
terminal behaves differently there. When you share a rg invocation — in a
README, a script, a bug report — spell the flags out. rg --no-config gives
you a clean run for reproducing someone else's result.
- Get the two populations:
Expect: 8 and 13. Five files are being skipped.cd "$SB" rg --files | wc -l find . -type f -not -path './.git/*' | wc -l - Name them:
Expect: ./.env, ./.gitignore, ./build/bundle.js, ./logs/app.log, ./node_modules/left-pad/index.js.comm -13 <(rg --files | sed 's|^|./|' | sort) \ <(find . -type f -not -path './.git/*' | sort) - Attribute each one to a filter:
Expect: three lines pointing at the project .gitignore (lines 1, 3, 2) and — if you have a global excludes file with a .env rule — a fourth naming that file instead. .gitignore itself is skipped as a dotfile, not by any rule.git check-ignore -v build/bundle.js logs/app.log node_modules/left-pad/index.js .env - Watch the count climb as you disable filters:
Expect: 8, then 11, then 31. The jump to 31 is almost entirely .git/hooks/*.sample — -uu stops hiding dot-directories, and .git is a dot-directory. That is why --hidden is rarely what you want on a repo; -g '!.git/*' pairs well with it.rg --files | wc -l rg --files --no-ignore | wc -l rg --files -uu | wc -l
Sources: ripgrep user guide · ripgrep FAQ · BurntSushi/ripgrep
10 · The rival greps — one of them wins
This section originally said "you do not need any of these." Then they
were installed and measured, and one of them beat ripgrep by a factor of four
while returning a byte-identical result set. The lesson is the one §15
keeps making: run the benchmark on your own corpus, and be willing to be wrong.
ack --version → ack v3.10.0 (standard build), running under Perl v5.34.3
ggrep --version → ggrep (GNU grep) 3.12
ugrep --version → ugrep 7.8.4 x86_64-apple-darwin24.6.0; -P:pcre2jit;
-z:zlib,bzip2,lzma,lz4,zstd,7z,tar/pax/cpio/zip
On the machine this guide was verified against they arrived from MacPorts
(/opt/local/bin), not Homebrew. Either works; the Homebrew spellings are
brew install ack, brew install grep (which installs GNU grep as
ggrep) and brew install ugrep. Worth confirming afterwards that
nothing shadowed the system tools — here /opt/local/bin sits ahead of
/usr/local/bin in PATH, yet bare grep still resolves
to /usr/bin/grep because none of these packages installs an unprefixed
grep. Check yours with which -a grep.
Measured, on the same corpus
Same query, same 39,798-file tree, same method as §15: best of
three warm runs, output to /dev/null.
| Command | Best of 3 | Lines | Files | vs rg |
|---|---|---|---|---|
ugrep -r 'func NewClient' ~/go | 129 ms | 88 | 71 | 4.0× faster |
ugrep -r --ignore-files … | 126 ms | 88 | 71 | 4.1× faster |
rg 'func NewClient' ~/go | 515 ms | 88 | 71 | baseline |
ag 'func NewClient' ~/go | 1,221 ms | 88 | 71 | 2.4× slower |
ggrep -rI 'func NewClient' ~/go | 2,100 ms | 88 | 71 | 4.1× slower |
ack 'func NewClient' ~/go | 5,453 ms | 88 | 71 | 10.6× slower |
grep -rI 'func NewClient' ~/go (BSD) | 10,169 ms | 88 | 71 | 19.7× slower |
The obvious suspicion is that ugrep won by searching less. It did not — it
searched more:
ugrep -r --stats 'func NewClient' ~/go
Searched 36644 files in 8673 directories in 0.73 seconds with 12 threads: 71 matching (0.1938%)
Searched 20397063 lines: 88 matching (0.0004314%)
rg --stats 'func NewClient' ~/go
32365 files searched
36,644 files against 32,365, and the two result sets are identical — comm
over the sorted -l output gives 71 shared, 0 unique to either. Every one of
the seven tools above agrees on 88 matching lines in 71 files.
What each one is actually for
| Tool | Its pitch | Verdict, having run it |
|---|---|---|
ugrep | A POSIX-compatible grep with PCRE2, fuzzy matching, archive search, an indexer and an interactive TUI. | Install it. Fastest here, does things nothing else in this guide can, and its options are grep's. |
ggrep (GNU grep 3.12) | The implementation the internet's grep advice assumes. | Install it. Not for speed — for -P, for -R actually following symlinks, and for scripts that must behave identically on Linux. |
ag | The 2013 C rewrite of ack that made "fast and gitignore-aware" the expectation. | Works, but nothing here is its best. Skip on a new machine. |
ack 3.10.0 | Perl, 2005. The first tool to skip VCS directories and filter by language. | Historically important, 10.6× slower than rg and 42× slower than ugrep. Its --type ideas live on in rg -t. |
The filtering models differ, and that changes answers
Run each against the §3 sandbox and the disagreement is the whole
story. TIMEOUT appears in seven files:
| Command | Files | Skips |
|---|---|---|
rg -l TIMEOUT . | 4 | .gitignore + global excludes + dotfiles |
ugrep -r -l --ignore-files TIMEOUT . | 4 | same four files as ripgrep — identical set |
ugrep -r -l TIMEOUT . | 6 | dotfiles only — no gitignore awareness by default |
ack -l TIMEOUT . | 7 | VCS directories, but not .gitignore and not dotfiles |
ggrep -rl TIMEOUT . --exclude-dir=.git | 7 | nothing but what you name |
ugrep defaults that are not grep's
It defaults to extended regex, not basic. ugrep 'a+' behaves
like grep -E 'a+'; POSIX grep 'a+' would match a literal plus.
For true drop-in behaviour pass -G:
printf 'aaa\na+b\ncat\n' | ugrep 'a+' → aaa · a+b · cat
printf 'aaa\na+b\ncat\n' | ugrep -G 'a+' → a+b
It ignores your .gitignore unless you ask. Add
--ignore-files and it lands on exactly ripgrep's answer. If you are switching
from rg, put that in an alias or you will get more results than you expect.
What ugrep does that nothing else here does
Search inside archives. -z descends into zip, tar, 7z and
the compressed variants — and reports the member path:
zip -q arch.zip docs/notes.md
ugrep -z -r 'TIMEOUT' arch.zip
arch.zip{docs/notes.md}:Set TIMEOUT in the config.
# ripgrep's -z handles gz/bz2/xz streams, not archive members:
rg -z 'TIMEOUT' arch.zip
binary file matches (found "\0" byte around offset 5)
Fuzzy matching. -Z takes an edit distance, so a
transposition still matches:
printf 'NewClient\nNewCleint\nNewClientsPool\n' | ugrep -Z2 'NewClient'
NewClient
NewCleint ← two transposed letters, still found
NewClientsPool
It also ships ug --query, an interactive full-screen search (the
ug command is ugrep with a user config file), and
ugrep-indexer, which builds an index to accelerate repeated searches over a
fixed tree — a fourth point on the index-versus-walk axis from
§1.
Install ripgrep, ugrep and GNU grep.
Keep rg as the daily driver — its gitignore behaviour is right by default, its
output is the friendliest, and 575 ms is not a number you can feel. Reach for
ugrep when you need an archive searched, a fuzzy match, a genuine
grep-compatible flag set, or raw speed over a very large tree. Keep ggrep for
portability and -P. Skip ack and ag.
pt is the name of the "platinum searcher", but on a Mac with
tcl-tk installed the command pt is Tcl's parser-tools front-end.
Run type -a pt before you believe a benchmark you ran against it.
Sources: Genivia/ugrep · ugrep user manual · GNU grep manual · ack — beyondgrep.com · ggreer/the_silver_searcher
11 · Spotlight and Finder — the same index, two front doors
The magnifying glass in your menu bar and the mdfind in your
terminal are two clients of one database. Everything you can express by clicking in
Finder can be written as a query string — and Finder will hand you that string if you know
where to look.
Three front doors
| Front door | Opens with | Best at | Weakness |
|---|---|---|---|
| Spotlight menu | ⌘Space — "Show or hide the Spotlight search field" | Launching, converting, calculating, and finding one known document fast | Ranked, truncated, and mixes files with apps, mail, web suggestions and definitions |
| Finder search | ⌘F in Finder, or ⌥⌘Space from anywhere | Browsing a result set: sort it, preview it, add criteria rows, act on many files | The criteria UI hides the real query behind pop-up menus |
| Smart Folder | ⌥⌘N, or File > New Smart Folder | A saved, self-updating query you can keep in the sidebar | Nothing — this is the underused one |
Finder's scope switch is a preference, and it has bitten everyone
When you press ⌘F, Finder searches either "This Mac" or the folder you were in — depending on a setting you probably never opened. Read and set it from the terminal:
defaults read com.apple.finder FXDefaultSearchScope
SCcf
# SCev = This Mac · SCcf = Current Folder · SCsp = Previous scope
defaults write com.apple.finder FXDefaultSearchScope -string SCcf
killall Finder
The GUI equivalent is Finder > Settings > Advanced > "When performing a search". If Finder search "never finds anything," this is the first thing to check — you have been searching one folder all along.
The bridge: a Smart Folder is a query in a plist
This is the part worth the price of admission. A saved search is a property list whose
RawQuery key holds a Spotlight query string — the exact dialect
mdfind speaks. Read one:
plutil -p ~/Library/Saved\ Searches/*.savedSearch | head -20
{
"CompatibleVersion" => 1
"RawQuery" => "(kMDItemUserTags = \"Gray\"cd) || ((_kMDItemFinderLabel = 1) && (kMDItemUserTags != \"*\"))"
"RawQueryDict" => {
"FinderFilesOnly" => 0
"RawQuery" => "(kMDItemUserTags = \"Gray\"cd) || ((_kMDItemFinderLabel = 1) && (kMDItemUserTags != \"*\"))"
"SearchScopes" => [
0 => "kMDQueryScopeComputer"
1 => "kMDQueryScopeNetworkIndexed"
]
"UserFilesOnly" => 1
}
"SearchCriteria" => { … the GUI's own representation of the same thing … }
Paste that RawQuery straight into mdfind and it runs:
mdfind '(kMDItemUserTags = "Gray"cd)'
/Users/you/Library/Mobile Documents/…/Nice_List_Certificate.pdf
/Volumes/thunderbay-private2/tmp/39953.mp4
Build the query in Finder, where the pop-up menus tell you which attributes exist and
what values they accept. Save it as a Smart Folder. Then plutil -p the file
to read out the query Apple's own UI generated, and reuse it in scripts. You get
discoverability from the GUI and automation from the shell, without learning the attribute
vocabulary by brute force.
Note the cd suffix on the value: case- and diacritic-insensitive.
§13 covers those modifiers.
The plain-language operators work in both places
What you type into the Spotlight field is a query language too, and
mdfind parses the same thing:
mdfind -onlyin ~ 'kind:pdf'
mdfind -onlyin ~ 'kind:pdf date:today'
mdfind -onlyin . 'name:report'
mdfind -onlyin . 'Zylophantic AND quarterly'
kind:, date:, name:, author:,
quoted "exact phrases" and uppercase AND / OR / NOT
behave the same in the menu-bar field and on the command line. That is one language to
learn, not two.
mdfind -interpret forces your string to be parsed "as if the user had
typed the string into the Spotlight menu." The man page shows the expansion for the word
search:
(* = search* cdw || kMDItemTextContent = search* cdw)
Any attribute, or the text content, matching a word that starts with your
term, case- and diacritic-insensitively, word-wise. That single line explains why the
Spotlight menu feels fuzzy and why mdfind returns more than you expected —
covered in §12.
- In Finder press ⌥⌘N for a new Smart Folder.
- Click + to add a criteria row. Set it to
Kind is PDF, add a second row for Last modified date is within last 30
days, then click Save and name it
Recent PDFs. - Read back what the GUI wrote:
Expect: a RawQuery line containing kMDItemContentTypeTree and a kMDItemFSContentChangeDate comparison against a $time.* function — Apple's own generated query, in the syntax of §13.plutil -p ~/Library/Saved\ Searches/Recent\ PDFs.savedSearch | grep -m1 RawQuery - Run that query yourself:
Expect: the same files the Smart Folder shows, in the terminal. If it errors, you probably kept the surrounding quotes from plutil's output — strip them.mdfind '<paste the RawQuery value here>' | head - Undo: delete the Smart Folder from the Finder sidebar, or
rm ~/Library/Saved\ Searches/Recent\ PDFs.savedSearch.
Sources: Apple — Search for anything with Spotlight on Mac · Apple — Create or change a Smart Folder on Mac · Apple — Mac keyboard shortcuts · mdfind(1)
12 · mdfind — Spotlight from the shell
mdfind is the most powerful search tool on your Mac and the
one most likely to lie to you, because it is the only one that does not search files. It
searches a description of your files that a fleet of importer plugins wrote when
those files were saved — and a description is not the thing.
mdfind can and cannot answer is decided at write time, days before you
ask.Getting started
# everything, anywhere, any attribute — usually far too much
mdfind Zylophantic
# scope it. -onlyin is the single most important flag.
mdfind -onlyin ~/spotlight-lab-tmp Zylophantic
/Users/you/spotlight-lab-tmp/report.txt
/Users/you/spotlight-lab-tmp/scratch.md
/Users/you/spotlight-lab-tmp/memo.rtf
# filenames only
mdfind -onlyin ~/spotlight-lab-tmp -name memo
/Users/you/spotlight-lab-tmp/memo.rtf
# count · NUL-safe · show attributes inline
mdfind -count -onlyin ~ 'kind:pdf'
mdfind -0 -onlyin ~ 'kind:pdf' | xargs -0 ls -lh
mdfind -onlyin ~/spotlight-lab-tmp -attr kMDItemContentType -attr kMDItemFSSize Zylophantic
…/report.txt kMDItemContentType = public.plain-text kMDItemFSSize = 65
…/scratch.md kMDItemContentType = net.daringfireball.markdown kMDItemFSSize = 40
…/memo.rtf kMDItemContentType = public.rtf kMDItemFSSize = 383
The lesson that matters most: words, not substrings
This is the difference between mdfind and every other tool in this guide,
and skipping it will cost you an afternoon. Here is the same question asked four ways
against the same 39,798-file tree:
| Command | Files returned | What it actually asked |
|---|---|---|
mdfind -onlyin ~/go 'func NewClient' | 726 | documents containing the word func and the word NewClient, anywhere, in any order |
mdfind -onlyin ~/go '"func NewClient"' | 84 | documents containing that phrase, case-insensitively |
mdfind -onlyin ~/go "kMDItemTextContent == '*func NewClient*'c" | 85 | the same, spelled as a raw query |
rg -uu -l 'func NewClient' ~/go | 71 | files containing that exact byte sequence |
The 84 and the 71 do not merely differ in size — they differ in membership. 70 files are in both. Both discrepancies are explainable, and each teaches a rule:
| Set | Example | Why |
|---|---|---|
14 files mdfind found and rg did not | x/crypto/ssh/handshake.go | it contains newClient — lowercase. Spotlight is case-insensitive; rg was not. |
1 file rg found and mdfind did not | cells/common/views/clients-pool.go, line 75: func NewClientsPool(…) | Spotlight tokenised that as the single word NewClientsPool. A quoted phrase demands exact whole tokens, and NewClient is not the token NewClientsPool. Spotlight matches tokens; rg matches bytes. |
The exact matching rule, measured
"Tokens, not substrings" is the headline, but the detail decides whether your query
works. One file containing the single line alpha NewClientsPool beta, asked
six ways:
| Query | Match? | Rule it demonstrates |
|---|---|---|
mdfind NewClient | yes | a bare word is a token prefix — mdfind -interpret shows why: it expands to search* |
mdfind ewClient | no | prefix only. Spotlight never matches the middle of a token. |
mdfind NewClientsPoolX | no | the prefix runs the other way — your term must be a prefix of the token, not vice versa |
mdfind 'alpha NewClient' | yes | unquoted words are separate terms, each prefix-matched |
mdfind '"alpha NewClient"' | no | quoting turns off prefix matching. A phrase needs exact whole tokens. |
mdfind "kMDItemTextContent == '*ewClient*'c" | yes | a raw query with leading and trailing * is a genuine substring search — the escape hatch when you need one |
If a bare word returns too much, quote it into a phrase. If a phrase returns too
little, you have hit the exact-token rule — drop to
kMDItemTextContent == '*text*'c, or stop asking Spotlight and pipe its
candidate files into rg (§14).
mdfind is a fast grep"
It is a fast search engine. It stems, tokenises, folds case and diacritics,
and ranks. It has no concept of a line, cannot show you context, cannot use a regular
expression, and will never match half a word. Use it to find candidate files,
then pipe them into rg or grep to find the actual lines. That
pipeline is in §14 and it is the best of both.
Two operational quirks you will hit in the first five minutes
1. It writes noise to stderr. Any query that goes through the user-syntax parser prints locale chatter:
mdfind -onlyin ~ 'kind:pdf'
2026-09-10 13:03:02.808 mdfind[10674:80508855] [UserQueryParser] Loading keywords and predicates for locale "en_US"
2026-09-10 13:03:02.808 mdfind[10674:80508855] [UserQueryParser] Loading keywords and predicates for locale "en"
/Users/you/Downloads/ISO_12233-reschart.pdf
Raw kMDItem… queries do not print it. In scripts, always
2>/dev/null. Some queries also emit
Couldn't determine the mapping between prefab keywords and predicates. —
harmless, and it does not stop the results.
2. It cannot see your temp directory.
/private/tmp and /private/var/folders are not indexed, which is
exactly where mktemp -d puts things. Build Spotlight sandboxes under
$HOME.
Where the index has holes
| Hole | Check it with | Fix |
|---|---|---|
| Whole volume not indexed | mdutil -a -s | sudo mdutil -i on /Volumes/NAME |
Folder name ends in .noindex | ls -d *.noindex | rename it — this one genuinely works |
| Spotlight Privacy list | System Settings → Siri & Spotlight → Spotlight Privacy… | remove the entry |
| File type has no importer, or an importer that stores no text | mdls -name kMDItemTextContent FILE | nothing to fix — use rg for that file |
| Index is behind | mdutil -s /, then wait | mdimport -r PATH to force one path |
| Index is corrupt | results are wrong in ways nothing else explains | sudo mdutil -E / — see the warning below |
mdutil -a -s
/:
Indexing enabled.
/System/Volumes/Data:
Indexing enabled.
/Volumes/thunderbay-private:
Indexing and searching disabled.
/Volumes/timemachine-8tb:
Indexing and searching disabled.
Two of this machine's six volumes are invisible to mdfind and always will
be until someone turns them on. If a search "cannot find" something on an external drive,
run this first.
mdutil -E erases the index
sudo mdutil -E / throws away the volume's index and rebuilds it from
scratch. On a multi-terabyte drive that is hours of background CPU and disk, during which
Spotlight and Finder search return partial results. It is a legitimate last resort for a
genuinely corrupt index, and a terrible first move. Try mdimport -r on the
specific path first.
.metadata_never_index did not work in a subfolder
The widely-repeated advice is to drop an empty .metadata_never_index file
into a folder to exclude it. Tested on macOS 15.6, with no forced mdimport
and 20 seconds for the background indexer:
# marker file present, then a new file created inside
mdfind -onlyin ~/spotlight-lab-tmp/never2 Qwixotrone
/Users/you/spotlight-lab-tmp/never2/late.txt ← still indexed
# same test, folder renamed to end in .noindex
mdfind -onlyin ~/spotlight-lab-tmp/x.noindex Qwixotrone
(nothing) ← blocked
The marker file is documented as a volume-root mechanism, not a per-folder
one. To exclude a folder reliably: rename it to end in .noindex, or add it
to the Spotlight Privacy list in System Settings.
Privacy protections apply to walking too
Some directories are unreadable even by you, because macOS's TCC layer gates them behind Full Disk Access. Every walking tool hits the same wall:
find ~/Library/Mail -maxdepth 1
/Users/you/Library/Mail
find: /Users/you/Library/Mail: Operation not permitted
find /Library/Application\ Support/com.apple.TCC -type f
find: /Library/Application Support/com.apple.TCC: Operation not permitted
Operation not permitted — as opposed to Permission denied — is the signature of TCC rather than of Unix file modes. Granting your terminal Full Disk Access in System Settings → Privacy & Security removes it, and is a decision worth making deliberately rather than to silence one error.
- Build it in
$HOME, because/tmpis not indexed:SL=~/spotlight-lab && mkdir -p "$SL" printf 'The quarterly revenue forecast mentions Zylophantic projections.\n' > "$SL/report.txt" printf 'Zylophantic notes, unrelated file name.\n' > "$SL/scratch.md" textutil -convert rtf -output "$SL/memo.rtf" "$SL/report.txt" mdimport "$SL" - Search by content and by name:
Expect: three files from the first (the .rtf too — Spotlight read inside it, which grep could not have done usefully), one from the second.mdfind -onlyin "$SL" Zylophantic 2>/dev/null mdfind -onlyin "$SL" -name memo 2>/dev/null - Walk the matching rule yourself — this is the step to do slowly:
Expect: 1, 0, 0, 1 — prefix yes, mid-token no, quoted phrase needs the exact token.printf 'alpha NewClientsPool beta\n' > "$SL/tokens.txt" mdimport "$SL" && sleep 4 for q in NewClient ewClient '"alpha NewClient"' '"alpha NewClientsPool"'; do printf '%-26s -> ' "$q" mdfind -onlyin "$SL" "$q" 2>/dev/null | grep -c tokens.txt done - Then find the escape hatch:
Expect: both print tokens.txt. A raw query wrapped in stars is a real substring search — the only way to get one out of Spotlight.mdfind -onlyin "$SL" "kMDItemTextContent == '*ewClient*'c" rg -l ewClient "$SL" - Compare a
.noindexfolder with the marker file:
Expect: marker/b.txt appears; hidden.noindex/a.txt does not. Only the folder-suffix mechanism worked on macOS 15.6.mkdir -p "$SL/hidden.noindex" "$SL/marker" && touch "$SL/marker/.metadata_never_index" printf 'Qwixotrone A\n' > "$SL/hidden.noindex/a.txt" printf 'Qwixotrone B\n' > "$SL/marker/b.txt" sleep 20 mdfind -onlyin "$SL" Qwixotrone 2>/dev/null - Read what the index actually stored:
Expect: a long attribute list, then kMDItemTextContent = (null) — the text is searchable but not readable back through mdls. The index is not a copy you can retrieve.mdls "$SL/report.txt" | head -12 mdls -name kMDItemTextContent "$SL/report.txt" - Clean up:
rm -rf ~/spotlight-lab
mdfind found a file rg missed and missed a file
rg found.Sources: mdfind(1) · mdutil(1) · mdimport(1) · mdls(1)
13 · The Spotlight query language
Raw queries are where mdfind stops being a worse
grep and becomes something nothing else on the machine can do: "PDFs over
5 MB, authored by someone, modified since Monday, tagged red." Nothing walks fast
enough to answer that. The syntax is small; the vocabulary is the hard part, and
mdls hands it to you.
Step one: ask a file what it knows about itself
mdls report.txt
kMDItemContentCreationDate = 2026-09-10 17:56:33 +0000
kMDItemContentType = "public.plain-text"
kMDItemContentTypeTree = (
"public.plain-text",
"public.text",
"public.data",
"public.item",
"public.content"
)
kMDItemDisplayName = "report.txt"
kMDItemFSName = "report.txt"
kMDItemFSSize = 65
kMDItemKind = "Plain Text Document"
kMDItemLogicalSize = 65
kMDItemPhysicalSize = 4096
…
Every left-hand name is a queryable attribute. mdls on a photo, a PDF and
a Pages document will show you three very different lists — that is the schema, per file
type. mdimport -X dumps the whole schema if you want the exhaustive
version.
Step two: the grammar
| Piece | Syntax | Example |
|---|---|---|
| Comparison | == != < > <= >= | kMDItemFSSize > 5000000 |
| Boolean | && || and parentheses | A && (B || C) |
| Value modifiers | c case-insensitive · d diacritic-insensitive · w word-based | == 'cafe.txt'cd |
| Wildcard | * — see the measured rules below | == '*.pdf' |
| Date functions | $time.now $time.today $time.yesterday $time.this_week $time.this_month $time.this_year $time.iso(…), each taking an optional offset | >= $time.today(-7) |
# modifiers are not decoration — without c, the case must match exactly
mdfind -onlyin "$SL" "kMDItemFSName == 'REPORT.TXT'c"
/Users/you/spotlight-lab-tmp/report.txt
mdfind -onlyin "$SL" "kMDItemFSName == 'REPORT.TXT'"
(nothing)
# d folds accents: this finds café.txt
mdfind -onlyin "$SL" "kMDItemFSName == 'cafe.txt'd"
/Users/you/spotlight-lab-tmp/café.txt
# PDFs touched in the last week, anywhere in $HOME
mdfind -onlyin ~ "kMDItemContentModificationDate >= \$time.today(-7) && kMDItemContentType == 'com.adobe.pdf'"
$ in double quotes
$time.today looks like a shell variable, because it is shaped exactly like
one. Inside double quotes zsh expands $time to nothing and your query
silently becomes >= .today(-7). Use single quotes for the whole query, or
write \$time as above.
Content types: the tree is what you want
kMDItemContentType is one exact Uniform Type Identifier.
kMDItemContentTypeTree is that type plus everything it conforms to, and it is
almost always the more useful field:
mdfind -onlyin "$SL" "kMDItemContentType == 'public.plain-text'"
report.txt ← exact type only
mdfind -onlyin "$SL" "kMDItemContentTypeTree == 'public.text'"
report.txt
scratch.md
memo.rtf ← everything that IS text
Useful UTIs: public.image, public.movie,
public.audio, public.text, public.source-code,
com.adobe.pdf, public.folder. Markdown is
net.daringfireball.markdown.
mdls on a .go source file reports
kMDItemContentType = "dyn.ah62d4rv4ge80s52" — a dynamic UTI, minted
because nothing on the system declares the go extension. Do not try to query
that string; it is not stable across machines. Match on
kMDItemFSName == '*.go' instead, or filter after the fact. The file's text is
still indexed by the plain-text fallback, which is why content searches find it anyway.
Wildcards: what actually works
Apple's reference says * matches "at the beginning, the end, or anywhere
within" a string and that ? matches a single character. Measured on macOS
15.6 against a file named report.txt, that is not quite the behaviour:
Pattern for kMDItemFSName | Hits | Reading |
|---|---|---|
'report.txt' | 1 | exact |
'report*' | 1 | trailing star — reliable |
'*port*' | 1 | surrounded — reliable |
'*t.txt' | 1 | leading — reliable |
'report.*' | 1 | star as a whole dot-separated component |
're*rt.txt' | 1 | star inside a component, no trailing literal component |
'repor*.txt' | 0 | fails — star mid-component with a literal .txt after it |
're*.txt' | 0 | fails — same shape |
'?eport.txt' | 0 | ? never worked in any position |
Stick to * at the start of the pattern, at the end, or both. Do not use
?. If you need real single-character or mid-pattern precision, get the
candidate list from mdfind and refine it with rg or
grep — that pipeline is §14.
InRange() is documented and returns nothing
Apple's query-syntax reference lists
InRange(attributeName,minValue,maxValue). On macOS 15.6 it matched nothing,
even for a range that provably contains the file:
mdfind -onlyin "$SL" "InRange(kMDItemFSSize,1,100000)"
(nothing — report.txt is 65 bytes)
mdfind -onlyin "$SL" "kMDItemFSSize > 50 && kMDItemFSSize < 100"
/Users/you/spotlight-lab-tmp/report.txt
Use two comparisons joined with &&. It is longer and it works.
Build a query
A working vocabulary
| Question | Query |
|---|---|
| Screenshots from this week | mdfind "kMDItemIsScreenCapture == 1 && kMDItemContentCreationDate >= \$time.this_week" |
| Big PDFs in Documents | mdfind -onlyin ~/Documents "kMDItemContentType == 'com.adobe.pdf' && kMDItemFSSize > 5000000" |
| Anything tagged Red | mdfind "kMDItemUserTags == 'Red'cd" |
| Downloaded from the web | mdfind "kMDItemWhereFroms == '*github*'c" |
| Images taller than 2000 px | mdfind "kMDItemPixelHeight > 2000" |
| Video longer than 10 minutes | mdfind "kMDItemDurationSeconds > 600" |
| Everything touched today, live | mdfind -live -onlyin ~ "kMDItemFSContentChangeDate >= \$time.today" |
-live is a monitor, not a search
It runs the query, prints the results, then stays attached and updates a running count
as matching files appear and disappear. CtrlC to stop. It is the
closest thing macOS gives you to tail -f for the filesystem, and it costs
nothing while idle because Spotlight is already watching.
- Discover the vocabulary rather than recalling it:
Expect: kMDItemAuthors, kMDItemNumberOfPages, kMDItemFSSize, kMDItemTitle — attributes that exist only because a PDF importer wrote them.mdls "$(mdfind -onlyin ~ 'kind:pdf' 2>/dev/null | head -1)" | grep -E 'Author|Page|Size|Title' - Ask a four-predicate question:
Expect: recent, largish PDFs. Note single quotes around the whole query so $time survives.mdfind -onlyin ~ 'kMDItemContentTypeTree == "com.adobe.pdf" && kMDItemFSSize > 1000000 && kMDItemContentModificationDate >= $time.today(-90)' 2>/dev/null | head - Time the nearest walking equivalent:
Expect: tens of seconds. Measured here: mdfind 0.89 s / 37 results vs fd 37.04 s / 54 results — a 42x difference, and the counts disagree because they are not the same question. fd sees every volume and every byte on disk; mdfind sees only indexed scopes, and dates the content rather than the inode.time fd -u -e pdf --size +1m --changed-within 90d . ~ | wc -lThen ask the walker for "PDFs by a given author." There is no flag, because the filesystem does not know. That is the whole argument for the index.
- Confirm the wildcard rule on your own files:
Expect: a healthy number, then 0 — the mid-component star with a literal component after it, exactly as in the table above.mdfind -onlyin ~ "kMDItemFSName == '*.pdf'" 2>/dev/null | wc -l mdfind -onlyin ~ "kMDItemFSName == 'r*.pdf'" 2>/dev/null | wc -l
Sources:
Apple — File Metadata Query Expression Syntax ·
mdls(1) ·
mdimport(1) — -X dumps the whole attribute schema
14 · Pipelines — using two tools where one will not do
The best macOS search is almost never one command. It is an index narrowing the field in milliseconds, handing a short list to a walker that answers precisely. Learn three joins and you can compose anything in this guide.
Join 1: index → walker (the important one)
mdfind finds candidate files fast but cannot show you a line.
rg shows you lines but must open everything. Chain them and each does the
part it is good at:
# "which of my indexed documents mentions this, and where exactly?"
mdfind -0 -onlyin ~/Documents 'invoice' 2>/dev/null \
| xargs -0 rg -H -n -i 'net 30'
Spotlight cuts a 40,000-file directory to a handful; ripgrep gives you file, line
number and context on those. This also rescues you from the token rule of
§12 — Spotlight finds the candidates loosely, rg
applies the exact pattern.
rg -H is not optional here
rg omits the filename when it is given exactly one file, so a pipeline
that usually prints path:line:text silently drops to line:text
on the day the candidate list has one entry. -H forces it. grep
has the same behaviour and the same flag.
Join 2: walker → walker
# fd decides which files; rg decides which lines
fd -t f -e md -0 | xargs -0 rg -H -n -i timeout
docs/notes.md:3:timeout matters.
# or let fd do the batching itself
fd -t f -e md -X rg -H -n -i timeout
Worth knowing: for plain "search these file types," rg -t markdown or
rg -g '*.md' is one process and faster. Reach for the pipeline when the file
selection needs something rg's globs cannot express — a size, an mtime, an
executable bit.
Join 3: anything → fzf
Every tool here can feed an interactive picker. This is where search stops being a command and becomes a way of moving around.
# pick a file from everything ripgrep would search, with a preview
rg --files | fzf --preview 'head -80 {}' --preview-window=right:60%
# jump to a directory
cd "$(fd -t d . ~ | fzf)"
# pick from Spotlight's answer instead of a walk
mdfind -onlyin ~ 'kind:pdf' 2>/dev/null | fzf --preview 'mdls {}'
Two functions worth putting in ~/.zshrc. The first is live grep — every
keystroke re-runs rg and the picker shows matching lines; the second opens
the chosen result at the right line:
# live ripgrep: type, see matching lines, press enter to open at that line
rgf() {
local f
f=$(fzf --ansi --disabled --layout=reverse \
--bind "change:reload:rg --column --line-number --color=always {q} || true" \
--delimiter : \
--preview 'rg --color=always --context 5 {q} {1} 2>/dev/null | head -60') || return
local file=${f%%:*} line=$(echo "$f" | cut -d: -f2)
${EDITOR:-vi} "+$line" "$file"
}
# Spotlight picker: fuzzy-filter an indexed search, open the winner
sf() {
mdfind -onlyin "${2:-$HOME}" "$1" 2>/dev/null \
| fzf --preview 'mdls -name kMDItemKind -name kMDItemFSSize -name kMDItemContentModificationDate {}' \
| xargs -o open
}
The fzf guide in this collection covers the picker itself; here it is only
the last stage of a search pipeline.
Join 4: search → structured output
# ripgrep speaks JSON — one object per event
rg --json TIMEOUT docs/notes.md | head -2
{"type":"begin","data":{"path":{"text":"docs/notes.md"}}}
{"type":"match","data":{"path":{"text":"docs/notes.md"},"lines":{"text":"Set TIMEOUT in the config.\n"},"line_number":2,"absolute_offset":8,"submatches":[{"match":{"text":"TIMEOUT"},"start":4,"end":11}]}}
# which makes counting per file a jq one-liner rather than an awk puzzle
rg --json -uu TIMEOUT . \
| jq -r 'select(.type=="match") | .data.path.text' \
| sort | uniq -c | sort -rn
The universal starting pattern
# 1 · narrow with the cheapest tool that can express the filter
# 2 · hand over a NUL-separated list
# 3 · finish with the tool that answers precisely
<narrower> -0 | xargs -0 <precise tool> -H -n <pattern>
| NUL flag | Tool |
|---|---|
-print0 | find |
-0 / --print0 | fd, locate, mdfind |
--null | rg, grep (with -l) |
-0 | xargs, on the receiving end — always |
- Two tools, one question:
Expect: report.txt and memo.rtf with line numbers. Spotlight picked the candidates including inside the RTF; ripgrep located the line.SL=~/spotlight-lab mdfind -0 -onlyin "$SL" Zylophantic 2>/dev/null | xargs -0 rg -H -n -i quarterly - Show yourself why
-Hmatters:
Expect: 3:timeout matters. from the first — no filename, because only one file was passed. docs/notes.md:3:timeout matters. from the second.cd "$SB" fd -t f -e md -0 | xargs -0 rg -n -i timeout fd -t f -e md -0 | xargs -0 rg -H -n -i timeout - Count matches per file without writing a loop:
Expect: seven paths each with a count of 1. (Needs jq — brew install jq.)rg --json -uu TIMEOUT . | jq -r 'select(.type=="match")|.data.path.text' | sort | uniq -c
-0 | xargs -0 without thinking, and you know why -H belongs on
the far end.Sources: junegunn/fzf · ripgrep user guide · xargs(1)
15 · Benchmarks — and what they do not mean
Numbers from someone else's machine are entertainment. These are from one specific Mac, on one specific corpus, and the reason they are here is not the ranking — it is that the tools return different answers, and the differences are the lesson.
Method
Intel Mac, macOS 15.6 (24G84), APFS internal SSD. Corpus:
~/go — 39,798 files, 3.0 GB, 22,079 of them .go. Each command
run three times with output to /dev/null; the best time is
reported. Every figure in both tables below comes from a single sitting, so the
tools are comparable with each other even though absolute numbers drift a few percent
between sittings. The page cache was warmed first with a full find pass, so these are
warm-cache numbers — a cold run costs several times more for the walkers and roughly
nothing for the index queries.
Finding files by name
| Command | Best of 3 | Results | What it really did |
|---|---|---|---|
fd -HI --glob '*.pb.go' ~/go | 121 ms | 3,888 | parallel walk, glob anchored to the basename |
mdfind -onlyin ~/go -name .pb.go | 209 ms | 3,891 | index lookup; -name is a substring match |
find ~/go -name '*.pb.go' | 629 ms | 3,888 | single-threaded walk |
locate '.pb.go' | 4,352 ms | 3,952 | linear scan of 11.2 M names across the whole disk |
find and fd agree on 3,888. mdfind -name and
locate, restricted to the same subtree, both return 3,891. The three extras:
…/gogoproto/gogo.pb.golden
…/minio-srv/vendor/…/gogoproto/gogo.pb.golden
…/protoc-gen-go/testdata/annotations/annotations.pb.go.meta
All three contain the string .pb.go; none ends with it.
-name '*.pb.go' is an anchored glob; locate and
mdfind -name are substring matches. Nothing is broken — three tools answered
three slightly different questions, and only one of them was the question you asked.
Finding text inside files
| Command | Best of 3 | Result | vs rg |
|---|---|---|---|
ugrep -r 'func NewClient' ~/go | 129 ms | 88 lines / 71 files | 4.0× faster |
mdfind -onlyin ~/go 'func NewClient' | 321 ms | 726 files | a different question entirely |
rg 'func NewClient' ~/go | 515 ms | 88 lines / 71 files | baseline |
rg -uu 'func NewClient' ~/go | 691 ms | 88 lines | 1.3× slower, same answer |
ag 'func NewClient' ~/go | 1,221 ms | 88 lines | 2.4× slower |
ggrep -rI 'func NewClient' ~/go | 2,100 ms | 88 lines | 4.1× slower |
ack 'func NewClient' ~/go | 5,453 ms | 88 lines | 10.6× slower |
grep -rI 'func NewClient' ~/go (BSD) | 10,169 ms | 88 lines | 19.7× slower |
Four points hide in that table.
One. mdfind looks competitive and is not competing. Its
726 files are documents containing the words func and NewClient
somewhere; the greps found 88 specific lines in 71 files. Comparing their times is
comparing a library catalogue to a book.
Two. Every grep-shaped tool here agrees: 88 matching lines in 71 files, no exceptions. An 80-fold spread in time and not one disagreement about the answer. When a search tool is slow it is doing the same work badly, not doing different work.
Three. rg -uu — every filter disabled — is 1.3× slower
than default rg here, because ~/go has few ignored files. On a
JavaScript monorepo with a 400 MB node_modules, that same flag is the
difference between one second and thirty. The filters, not the threads, are ripgrep's
biggest lever on real projects.
Four. BSD grep's 20× deficit is not incompetence, it is
architecture: single-threaded, opens every file, no literal prefilter. GNU grep 3.12 closes
about five-sixths of that gap without changing the model at all. The rest of the distance —
rg and ugrep — is parallelism plus SIMD literal scanning
(rg --version reports simd(runtime):+SSE2,+SSSE3,+AVX2).
That ugrep is four times faster again than rg on this corpus is
the result that made §10 get rewritten.
- Cold cache. Everything here ran warm. First-run times on a large tree are dominated by disk, and the ranking compresses.
- Your corpus. A tree of 40,000 small Go files behaves nothing like 200 video files or a 12 GB Xcode DerivedData folder.
- Index freshness.
mdfind's 321 ms assumes Spotlight is caught up. Right after copying 50 GB in, it is not, and it will answer confidently anyway. - Apple silicon. This was an Intel Mac. Expect the walkers to close the gap on an M-series machine with more performance cores and faster storage.
Re-run the method above on your own corpus before you believe any of it. The script is four lines and the habit is worth more than the table.
Sources: measured on the machine described above, 2026-09-10 · Andrew Gallant — ripgrep is faster than {grep, ag, git grep, ucg, pt, sift}, for the methodology this section imitates
16 · Choosing, without thinking about it
Three questions, asked in order, pick the tool every time. You will internalise them in a week and never consult this section again — which is the point.
The decision matrix
| You want… | Use | Because |
|---|---|---|
| A string in the project you are standing in | rg PATTERN | fast, respects .gitignore, gives line numbers |
| …including ignored and hidden files | rg -uu PATTERN | the filters, not the speed, are what changed the answer |
| A string in one known file | grep -n PATTERN file | no reason to reach further |
| A regex with lookahead or a backreference | rg -P PATTERN | also ugrep -P / ggrep -P if installed (§10) |
| A string inside a zip, tar or 7z | ugrep -z -r PATTERN ARCHIVE | the only tool here that searches archive members |
| A string you might be misspelling | ugrep -Z2 PATTERN | fuzzy matching by edit distance; nothing else here has it |
| Raw throughput over a very large tree | ugrep -r --ignore-files | 4× faster than rg here, identical results (§15) |
| A filename inside a tree you can name | fd PATTERN DIR | current, parallel, smart case |
| Filenames, then act on each one | find … -exec … {} + or fd … -X | batching without an xargs quoting bug |
| A file by size, kind, tag, author, EXIF, duration | mdfind "kMDItem… " | nothing else has that metadata at all |
| A document whose contents you half-remember | mdfind 'phrase' | it read inside your PDFs and Pages files; grep cannot |
| The system file you cannot place | locate NAME | one query covers /usr, /opt, /Library, every SDK |
| Something you created in the last hour | fd or rg | both indexes may be behind; a walk cannot be |
Anything inside ~/Documents or ~/Library | not locate | mode 700 means nobody never saw it |
Anything in $TMPDIR | not locate, not mdfind | both prune /private/tmp and /private/var/folders |
| Portability to Linux in a script | find + grep, or ggrep | they are the only two guaranteed to be there |
Translation table, if you came from Linux
| Linux instinct | macOS move | Why it is different |
|---|---|---|
grep -P '\d+' | rg -P, ugrep -P or ggrep -P | BSD grep has no -P — though \d itself works without it |
find . -printf '%p\n' | find . -print, or gfind | -printf is a GNU extension |
find -regextype posix-extended | find -E . -regex … | the flag moves in front of the path |
updatedb then locate | sudo /usr/libexec/locate.updatedb | weekly job, runs as nobody, ships disabled |
locate finds everything | mdfind finds everything | on macOS the whole-machine index is Spotlight's, not locate's |
grep -R follows symlinks | it does not, here — use ggrep -R | -r and -R are one flag on BSD grep; measured in §7 |
ls -l tells you everything | mdls FILE | macOS keeps a second, richer set of facts about every file |
17 · Capstone — the four-phase hunt
One scenario, four phases, every tool in the guide. The premise: you half-remember a PDF invoice from earlier this year, you think a config file somewhere mentions the same client, and you want to know why one of your searches keeps coming up empty.
Phase 1 · Build a corpus Spotlight can see
Expect: no output. Five files across three directories, one of which Spotlight will refuse to index.CL=~/capstone-lab && mkdir -p "$CL"/{invoices,config,archive.noindex} printf 'Invoice 2026-0412\nClient: Blorbtech Industries\nTerms: net 30\nAmount: 4200.00\n' > "$CL/invoices/inv-0412.txt" textutil -convert rtf -output "$CL/invoices/inv-0412.rtf" "$CL/invoices/inv-0412.txt" printf 'client_name = "Blorbtech"\ntimeout = 30\n' > "$CL/config/app.toml" printf 'BlorbtechLegacyExport was archived here\n' > "$CL/archive.noindex/old.txt" printf 'secret_client=Blorbtech\n' > "$CL/config/.env" printf '*.log\n.env\n' > "$CL/config/.gitignore" git -C "$CL/config" init -q mdimport "$CL" && sleep 5
Phase 2 · Ask each tool the same question and predict the answers first
- Write down your prediction for each, then run them:
Expect, exactly: rg -l → 4 : app.toml, inv-0412.rtf, inv-0412.txt, archive.noindex/old.txt rg -uu -l → 5 : the above plus config/.env grep -rl → 5 : the same five, by a different route mdfind → 3 : inv-0412.txt, inv-0412.rtf, app.toml fd -u -t f → 24 : every file including all of .git/hooks/*.sample Two facts do all the work here. .noindex means nothing to a walker — rg happily searched the archive folder that Spotlight refuses to index. And .env is invisible to rg (a dotfile, and gitignored) AND to mdfind (Spotlight does not surface dotfiles), but plainly visible to grep -r, which filters nothing.rg -l Blorbtech "$CL" rg -uu -l Blorbtech "$CL" grep -rl Blorbtech "$CL" mdfind -onlyin "$CL" Blorbtech 2>/dev/null fd -u -t f . "$CL" - Attribute each absence to a mechanism:
Expect: .gitignore:2:.env .env kMDItemContentType = "public.plain-text" (nothing) The middle line is the subtle one: mdls happily reports a content type for a file Spotlight never indexed, because mdls inspects the file itself rather than querying the index. mdls answering is not evidence that mdfind will.git -C "$CL/config" check-ignore -v .env mdls -name kMDItemContentType "$CL/archive.noindex/old.txt" mdfind -onlyin "$CL/archive.noindex" Blorbtech 2>/dev/null
Phase 3 · Use the index to narrow, the walker to answer
Expect: …/invoices/inv-0412.txt:3:Terms: net 30 …/invoices/inv-0412.rtf:9:Terms: net 30\ Both, including the RTF — Spotlight nominated it by reading inside the format, and ripgrep found the literal because RTF keeps plain runs of text between its control words. That trailing backslash is RTF markup, not a typo. Spotlight chose the candidates; ripgrep found the lines.mdfind -0 -onlyin "$CL" Blorbtech 2>/dev/null | xargs -0 rg -H -n 'net 30'- Now the metadata question no walker can answer:
Expect: …/invoices/inv-0412.rtf kMDItemFSSize = 398 kMDItemKind = "Rich Text Document"mdfind -onlyin "$CL" 'kMDItemContentTypeTree == "public.rtf" && kMDItemFSSize > 100' 2>/dev/null mdls -name kMDItemKind -name kMDItemFSSize "$CL/invoices/inv-0412.rtf"
Phase 4 · Take it to your real disk
- Same shape, real data — substitute a client, project or person you actually have:
mdfind -0 -onlyin ~ 'kind:pdf YOURTERM' 2>/dev/null \ | xargs -0 mdls -name kMDItemFSSize -name kMDItemContentModificationDate \ | head -20 - Ask the same question three ways and time all three:
Expect: three different counts and three very different times. If you can explain all three gaps without re-reading the guide, you are done.time mdfind -count -onlyin ~ 'YOURTERM' 2>/dev/null time rg -l YOURTERM ~ 2>/dev/null | wc -l time locate -c YOURTERM - Clean up everything this guide created:
rm -rf ~/capstone-lab ~/spotlight-lab "$SB"
.gitignore,
global excludes, dotfile, .noindex, mode 700, unindexed volume, staleness,
substring versus token, or anchored versus unanchored pattern. That list is the whole
guide.18 · Troubleshooting
Almost every complaint is "it should have found that." The table maps symptom to mechanism to fix. Work down it in order — the cheap checks are first.
Nothing found, but the file exists
| Symptom | Likely cause | Fix / check |
|---|---|---|
rg finds nothing, grep -r finds it | an ignore rule | git check-ignore -v PATH; then rg -uu |
rg --hidden still misses it | your global gitignore (core.excludesFile) | git config --get core.excludesFile; rg --no-ignore-vcs |
rg misses a lowercase/uppercase variant | rg is case-sensitive by default | rg -i or rg -S |
fd 'ABC' finds nothing, fd 'abc' works | smart case: a capital made it sensitive | fd -i |
fd -e pdf ~ silently returns nothing | ~ was taken as the pattern | fd . ~; read stderr, fd explains it |
locate misses a file you made today | the database is up to a week old | stat -f '%Sm' /var/db/locate.database; use fd |
locate never finds anything in ~/Documents | the indexer runs as nobody; mode 700 blocks it | structural — use mdfind or fd |
locate: /var/db/locate.database: No such file or directory | the launchd job ships disabled | sudo launchctl load -w /System/Library/LaunchDaemons/com.apple.locate.plist |
mdfind misses a file on an external drive | indexing off for that volume | mdutil -a -s; sudo mdutil -i on /Volumes/NAME |
mdfind misses a whole folder | folder name ends in .noindex, or it is in Spotlight Privacy | rename it; System Settings → Siri & Spotlight → Spotlight Privacy… |
mdfind misses part of a word | token prefix matching — never mid-token | "kMDItemTextContent == '*frag*'c", or pipe to rg |
A quoted mdfind phrase returns nothing | quoting disables prefix matching; tokens must match exactly | drop the quotes, or use the raw wildcard form |
Nothing finds anything in $TMPDIR | /private/var/folders is pruned from both indexes | use fd/rg, or work under $HOME |
| Search misses files inside a symlinked directory | nothing here follows symlinks by default | fd -L · rg -L · find -L · ggrep -R (BSD grep -R will not) |
Errors and refusals
| Message | Cause | Fix |
|---|---|---|
find: retry.js: unknown primary or operator | unquoted glob expanded by the shell | quote the pattern: -name '*.js' |
zsh: no matches found: *.js | same, but nothing matched, so zsh aborted | quote it |
find: -printf: unknown primary or operator | GNU-only predicate on BSD find | -print, or brew install findutils → gfind |
grep: invalid option -- P | BSD grep has no PCRE | rg -P · ugrep -P · ggrep -P · pcre2grep |
grep: repetition-operator operand invalid | lookahead written for PCRE, run through ERE | rg -P |
grep: invalid backreference number | (ab)\1 in BRE, or \(ab\)\1 in ERE | match the escaping to the dialect (§8) |
rg: regex parse error … look-around … is not supported | Rust regex has no backtracking | rg -P, exactly as the message says |
rg: regex parse error … repetition operator missing expression | you passed a glob where a regex was expected | rg -g '*.js' PATTERN |
find: …: Operation not permitted | TCC, not file modes — ~/Library/Mail, TCC.db, and friends | grant the terminal Full Disk Access, deliberately |
wc: ./with: open: No such file or directory | a path with a space crossed a pipe without NUL separation | -print0/-0 and xargs -0 |
mdfind[…] [UserQueryParser] Loading keywords… | informational stderr chatter | 2>/dev/null; raw kMDItem queries do not print it |
Query with $time.today silently matches everything | zsh expanded $time inside double quotes | single-quote the query, or write \$time |
Too many results, or the wrong ones
| Symptom | Cause | Fix |
|---|---|---|
mdfind word returns thousands | bare words are token prefixes, across every attribute | quote the phrase; add -onlyin; use a raw kMDItem… query |
locate name returns Xcode SDK copies of everything | substring match over the entire disk | anchor it: locate '/usr/bin/name' |
grep -r is drowning in node_modules | grep has no ignore rules | use rg, or --exclude-dir |
| Results differ every run | fd and rg walk in parallel | rg --sort path · fd --threads 1 · pipe to sort |
| A file appears twice | a hard link, or a firmlinked path under /System/Volumes/Data | ls -li to compare inode numbers |
Performance
| Symptom | Cause | Fix |
|---|---|---|
grep -r takes minutes | single-threaded, opens everything | rg is 20× faster here, ugrep -r 79× (§15) |
rg is slow on one project | a giant vendored or build directory is not ignored | add it to .ignore; check with rg --files | wc -l |
ugrep returns more than rg did | it does not read .gitignore unless told | ugrep -r --ignore-files — lands on ripgrep's exact set (§10) |
A pattern that was literal under grep becomes a quantifier under ugrep | ugrep defaults to ERE, not BRE | ugrep -G, or write the pattern for ERE |
locate takes five seconds | linear scan of an 11-million-name file | expected; use fd if you know the tree |
mdfind is slow right after a big copy | Spotlight is still importing | mdutil -s /; wait, or mdimport -r a specific path |
| Fans spin up for hours after plugging in a drive | first-time indexing of that volume | expected once; sudo mdutil -i off /Volumes/NAME if you never search it |
19 · Cheat sheet
The universal starting pattern
# narrow with the cheapest tool, hand over NUL-separated, finish precisely
<narrower> -0 | xargs -0 <precise tool> -H -n <pattern>
# the three you will type every day
rg PATTERN # text, here, now
fd PATTERN [DIR] # a filename, in a tree you can name
mdfind -onlyin DIR 'kind:pdf' # a property only the index knows
find
find . -name '*.js'— always quotefind . -type f -mtime -7find . -size +10Mfind . -newer FILEfind . \( -name build -o -name node_modules \) -prune -o -printfind . -name '*.tmp' -exec rm {} +find -E . -regex '.*/(a|b)\.js'find -s .— sorted (BSD only)- no
-printf;-maxdepthgoes first
fd
fd PATTERN [DIR]— one arg = patternfd . ~— match-all in a directoryfd -e md -e txtfd -t f·-t d·-t l·-t x·-t efd -Hhidden ·-Ino-ignore ·-ubothfd --changed-within 2weeksfd -S +1m— by sizefd -x cmd {}·-X cmd- smart case;
-sto force sensitive
locate
locate NAME— substring, whole disklocate '/usr/bin/md*'— glob if metalocate -i·-c·-l N·-0locate -S— database statsstat -f '%Sm' /var/db/locate.databasesudo /usr/libexec/locate.updatedb- weekly · runs as
nobody· skips mode 700 - skips exFAT/SMB/NFS and
/private/tmp
grep (BSD)
grep -rn PAT .grep -EERE ·-Fliteral ·-GBREgrep -i -w -v -c -l -ogrep -C2 PAT filegrep -q PAT file— status onlygrep -rn --include='*.js' PAT .- exit 0 match · 1 no match · 2 error
- no
-P;-r=-R \d \w \s \bdo work here
ripgrep
rg PATTERN— case-SENSITIVErg -Ssmart ·-iinsensitiverg -uu— ignore rules + hidden offrg -t js·-g '*.md'·-g '!vendor/*'rg --files— what it would searchrg -l·-c·-o·-r '$1'rg -A2 -B2·--sort pathrg -P— lookaround, backrefsrg --stats·--debug·--json
mdfind
mdfind -onlyin DIR 'words'mdfind -name NAME— substringmdfind -count·-0·-livemdfind -attr kMDItemFSSize QUERYmdfind -interpret 'x'— as the menu wouldmdls FILE— the attribute vocabularymdutil -a -s·mdimport -r PATH- always
2>/dev/nullin scripts - bare word = token prefix; quotes = exact tokens
Spotlight query syntax
== != < > <= >=·&&||()'value'ccase ·'value'daccents ·'value'wwords*at the ends only;?never works$time.today(-7),$time.this_week- single-quote the query so
$timesurvives InRange()is documented and broken — use two comparisonskMDItemContentTypeTree>kMDItemContentType
ugrep & ggrep
ugrep -r PAT DIR— ERE by defaultugrep -G— BRE, the true drop-inugrep -r --ignore-files— obey .gitignoreugrep -P·ggrep -P— PCRE2ugrep -z -r PAT file.zip— inside archivesugrep -Z2 PAT— fuzzy, edit distance 2ug --query— interactive TUIggrep -R— actually follows symlinksugrep -r --stats— files/lines scanned
Diagnostics
git check-ignore -v PATHrg --files | wc -lrg --debug PATTERNmdutil -a -smdls -name kMDItemTextContent FILElocate -Stype -a grepls -li— inodes and the@xattr marker
Shell safety, in four lines
# quote every pattern containing * ? [ ] or a space
find . -name '*.log'
# NUL-separate every filename that crosses a pipe
fd -0 … | xargs -0 …
# force the filename when a pipeline may pass exactly one file
… | xargs -0 rg -H -n PATTERN
# never let a no-match kill a set -e script
if rg -q PATTERN file; then … ; fi
20 · Glossary
- anchored pattern
- A pattern that must match the whole subject, not part of it.
find -name '*.txt'is anchored to the basename, which is why it will not matcha.txt.bak;locate '.txt'is unanchored and will. (§2) - BRE — basic regular expression
- POSIX's older regex dialect, the default for
grepandsed. Quantifiers and groups are literal unless escaped:\+,\|,\{3\},\(…\). (§8) - ERE — extended regular expression
- POSIX's modern dialect, reached with
grep -E,egrep,find -Eorawk. Metacharacters are operators by default and escaping makes them literal. (§8) - dynamic UTI
- A generated content-type identifier such as
dyn.ah62d4rv4ge80s52, minted when no installed application or system declaration claims a file extension. Not stable between machines, so never query it. (§13) - excludesFile (global gitignore)
- The path in
core.excludesFile, applied to every repository on the machine.rgandfdhonour it, which is why a file can be invisible to them with nothing in the project's own.gitignore. (§9) - firmlink
- An APFS link that joins the read-only System volume to the writable Data volume, so
/Usersand/System/Volumes/Data/Usersare the same directory.locate.updatedbprunes firmlink targets specifically to stop every path being indexed twice. (§6) - glob
- The shell's filename-matching language:
*any run,?one character,[abc]a set. Implicitly anchored, and expanded by the shell before the command runs unless quoted. Not a regex. (§2) - index
- A precomputed answer to a class of questions, with a freshness date and a scope.
locate's is a weekly text file; Spotlight's is a live per-volume database. Neither is the disk. (§1) - inode
- The filesystem object holding a file's data and metadata. Names in directories point
at inodes, so one inode can have several names (hard links). Shown by
ls -li. (§2) - kMDItem attribute
- One key in Spotlight's per-file metadata record —
kMDItemFSSize,kMDItemContentType,kMDItemUserTags,kMDItemTextContentand hundreds more. List them for any file withmdls. (§13) - mdimporter
- A plugin that teaches Spotlight to read one family of file formats. 27 ship with
macOS 15.6 (
mdimport -L); apps install more. If no importer understands a format, its contents are never indexed. (§12) - .noindex
- A directory-name suffix that excludes a folder and its contents from Spotlight.
Verified working on macOS 15.6 — unlike the
.metadata_never_indexmarker file, which did not block indexing when placed in a subfolder. (§12) - NUL separation
- Passing filenames between processes terminated by a zero byte rather than a newline,
because NUL is the only character a path cannot contain.
find -print0/fd -0/mdfind -0on one side,xargs -0on the other. (§4) - PCRE2
- Perl-Compatible Regular Expressions, version 2 — the dialect with lookahead,
lookbehind and backreferences. On a stock-plus-Homebrew Mac it is reached through
rg -P. This machine's ripgrep bundles PCRE2 10.45 with JIT. (§8) - prune
- To refuse to descend into a directory.
find … -prunenever opens it at all, which is why it beats filtering the results with-not -path. (§4) - RawQuery
- The key inside a Finder saved search (
.savedSearchplist) holding the Spotlight query string the GUI generated. Readable withplutil -pand runnable verbatim inmdfind. (§11) - smart case
- Case-insensitive matching that turns itself off when the pattern contains an uppercase
letter.
fddoes this by default;rgrequires-S. (§5) - TCC
- macOS's Transparency, Consent and Control layer — the privacy gate above Unix permissions. Its refusals read Operation not permitted rather than Permission denied, and are lifted by granting Full Disk Access. (§12)
- token
- The unit Spotlight indexes text in — roughly a word, split on punctuation and case
boundaries the importer chose.
mdfindmatches token prefixes and never the middle of a token, which is why it can miss text that plainly exists. (§12) - UTI — Uniform Type Identifier
- macOS's reverse-DNS type name (
public.plain-text,com.adobe.pdf) arranged in a conformance hierarchy.kMDItemContentTypeTreematches anywhere in that hierarchy;kMDItemContentTypeonly at the leaf. (§13) - walk
- Reading directory entries from the filesystem right now, in whatever order it supplies them. Always current, never stale, and priced in I/O. (§1)
- extended attribute (xattr)
- Arbitrary key/value data stored alongside a file — Finder tags, quarantine flags,
download provenance. Marked by
@inls -l, listed byxattr -l, and indexed by Spotlight askMDItem*attributes. Invisible togrep. (§2)
21 · Index
--exclude-dir → §7--files → §9--sort path → §9--stats → §9-E (grep) → §8-E (find) → §4-P (rg) → §8-S smart case → §9-exec {} + → §4-live → §13-onlyin → §12-print0 → §4-prune → §4-uu → §9$time.today → §13d) → §13dyn. UTIs → §13fd → §5find → §4fzf → §14grep → §7InRange() (broken) → §13kMDItem* → §13locate → §6locate.updatedb → §6mdfind → §12mdimport → §12mdls → §13mdutil → §12.metadata_never_index → §12.noindex → §12nobody, locate runs as → §6plutil → §11RawQuery → §11RIPGREP_CONFIG_PATH → §9rg (ripgrep) → §9.savedSearch → §11set -e and grep → §7type -a → §3ugrep -z (archives) → §10ugrep -Z (fuzzy) → §10xargs -0 → §4no matches found → §222 · Quiz
Seventeen questions, self-scored. Answer out loud before you reveal — recognising an answer is not the same as producing one. Aim for 15 of 17 before you call this learned.
mdfind on both.mdfind is indexed on the first axis and occupies both cells on the second — it is the only tool here that searches contents from an index. (§1)find . -name *.js fail differently depending on what is in the current directory?find reports unknown primary or operator; zero matches make zsh abort with no matches found. Quote it. (§2)fd and rg is smart-case by default?rg --hidden still does not show your .env. What is the one command that tells you why?git check-ignore -v .env. It names the file, line number and rule — frequently your global excludes file rather than the project's .gitignore. (§9)locate never finds anything in ~/Documents. Is this a bug, and can you fix it by rebuilding?locate.updatedb drops to the user nobody before walking, and ~/Documents is mode 700. No rebuild changes that — it is a deliberate privacy property. Use mdfind or fd. (§6)locate's answer be, and how do you check?stat -f '%Sm' /var/db/locate.database or locate -S. (§6)grep support \d? Does it support -P?\d yes — in BRE and ERE, verified on macOS 15.6, along with \w, \s and \b. -P no: grep: invalid option -- P, exit 2. The portable spelling is still [[:digit:]]. (§8)a\+ mean "one or more a", and in which does it mean a literal plus?grep). Literal + in ERE, Rust regex and PCRE2 — where the unescaped a+ is the quantifier. The escaping flips meaning across the BRE/ERE boundary. (§8)regex crate omits look-around and backreferences to guarantee worst-case O(m*n) time — no catastrophic backtracking. The escape hatch is rg -P (PCRE2), which the error message names. (§8)NewClientsPool. Which of mdfind NewClient, mdfind ewClient and mdfind '"alpha NewClient"' match it?kMDItemTextContent == '*ewClient*'c. (§12)mdfind can be blind to a file that certainly exists.mdutil -a -s); the folder name ends in .noindex or is in Spotlight Privacy; the path is under /private/tmp or /private/var/folders; no importer handles the format; the index has not caught up yet; it is a dotfile. (§12)RawQuery key of the .savedSearch plist under ~/Library/Saved Searches. Read it with plutil -p and paste it straight into mdfind — build queries in the GUI, run them in the shell. (§11)InRange(attr,min,max) returns nothing — use attr > min && attr < max. And the single-character wildcard ? never matches — restrict yourself to * at the start or end of the pattern. (§13)find … | xargs a bug, and what are the two correct forms?-exec cmd {} +, or -print0 | xargs -0. NUL is the only byte a path cannot contain. (§4)locate return 3,952 results where find -name '*.pb.go' returned 3,888?locate searched the whole disk, not one subtree, and it matches substrings — so gogo.pb.golden and annotations.pb.go.meta qualify. -name '*.pb.go' is an anchored glob. (§15)mdfind -0 -onlyin DIR 'terms' 2>/dev/null | xargs -0 rg -H -n 'exact pattern'. Index narrows, walker answers; -0/-0 for safety, 2>/dev/null for the parser chatter, -H because rg hides the filename when given one file. (§14)ugrep, rg, ag, ggrep, ack and BSD grep agreed exactly. Speed differences here are the same work done better, not different work. The tools that do disagree disagree for a filtering or matching-model reason: rg obeys .gitignore, ugrep does not unless told, and mdfind is answering a different question altogether. (§10, §15)23 · Sources
Every transcript, timing and count in this guide was produced on one machine —
macOS 15.6 (24G84), Intel, APFS — on 2026-09-10, with
fd 10.5.0, ripgrep 15.2.0 (PCRE2 10.45, JIT),
grep (BSD grep, GNU compatible) 2.6.0-FreeBSD, ugrep 7.8.4,
ggrep (GNU grep) 3.12, ag 2.2.0, ack v3.10.0 and
fzf 0.74.3. Where behaviour contradicted the documentation, the guide records
what happened and says so.
§10 was written before ack, ugrep and
ggrep were installed, said they were not worth having, and was rewritten after
measuring them — ugrep turned out to be the fastest tool in the guide. The
first version is not preserved; the revision is the point.
External links need a network; the guide itself does not, and makes zero external requests.
Apple
- File Metadata Query Expression Syntax
Best used for: the authoritative operator, modifier, wildcard and
$time.*tables. Archived, and §13 records the two places it no longer matches behaviour. - Search for anything with Spotlight on Mac Best used for: the supported ways to open and use the Spotlight field, per macOS version.
- Create or change a Smart Folder on Mac Best used for: the GUI half of the §11 round trip — File > New Smart Folder, criteria rows, saving.
- Mac keyboard shortcuts Best used for: confirming ⌘Space, ⌥⌘Space, ⌘F and ⌥⌘N in Apple's own words rather than from folklore.
Manual pages
- Keith Smiley's macOS man page mirror
Best used for: reading mdfind(1), mdls(1), mdutil(1), mdimport(1), find(1), grep(1), locate(1) and re_format(7) in a browser. The same text is on your disk via
man. /usr/libexec/locate.updatedband/System/Library/LaunchDaemons/com.apple.locate.plistBest used for: the ground truth of §6 —PRUNEPATHS,FILESYSTEMS, thesu -fm nobodyline and the weekly schedule. Both are readable plain text on your own Mac.
The tools
- ripgrep — user guide
Best used for: the filtering model, the
-u/-uu/-uuuladder, file types, globs andRIPGREP_CONFIG_PATH. The single best document about any tool in this guide. - ripgrep — FAQ Best used for: PCRE2, shell completions, and the "why didn't it search X" questions §18 turns into a table.
- sharkdp/fd — README
Best used for: the full option list, the
-x/-Xplaceholder syntax, and the design rationale for the default exclusions. - Rust
regexcrate — syntax Best used for: exactly which constructs ripgrep supports without-P, and the linear-time guarantee that is the reason for the omissions. - PCRE2 syntax summary
Best used for: what
rg -Punlocks — lookaround, backreferences, named groups, possessive quantifiers. - GNU grep manual
Best used for: knowing what the internet's grep advice assumes, and therefore which parts will not work until you
brew install grep. - Genivia/ugrep · ugrep user manual
Best used for: the archive (
-z), fuzzy (-Z), indexer and interactive-query features that make §10 recommend it, and the full list of the grep options it is compatible with. - ggreer/the_silver_searcher · ack Best used for: the history §10 compresses — ack invented the VCS-aware, type-filtered search that every tool here now assumes.
- junegunn/fzf
Best used for: the
--preview,--bindandreloadoptions the §14 shell functions depend on.
Standards and background
- POSIX.1-2017 — Regular Expressions Best used for: settling what BRE and ERE are actually required to do, as opposed to what a given implementation happens to allow.
- POSIX —
find· POSIX —grepBest used for: writing scripts that survive on both macOS and Linux — the intersection, not either dialect. - Andrew Gallant — ripgrep is faster than {grep, ag, git grep, ucg, pt, sift} Best used for: how to benchmark search tools honestly. §15 imitates its method and its caveats.