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.

macOS 15.6 · 24G84 fd 10.5.0 ripgrep 15.2.0 (+pcre2 10.45) BSD grep 2.6.0-FreeBSD ugrep 7.8.4 · GNU grep 3.12 ag 2.2.0 · ack 3.10.0 verified 2026-09-10 single file · works offline regex playground · query builder · real benchmarks

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.

Misconception: "indexed" means "fast"

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 ms15). 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 two-by-two of macOS file search A grid with index versus live walk on the vertical axis and filename versus file contents on the horizontal axis. mdfind and locate sit in the indexed row, find and fd in the walking-by-name cell, grep and ripgrep in the walking-by-contents cell, and mdfind also occupies the indexed-contents cell. MATCHES FILE NAME & METADATA FILE CONTENTS SOURCE ASK AN INDEX precomputed; stale + scoped mdfind -name … Spotlight metadata store · live within seconds knows size, kind, dates, tags, EXIF, author locate PATTERN /var/db/locate.database · rebuilt weekly, as nobody mdfind 'phrase' the only indexed content search on the box matches words, not substrings — see §12 no regex · no line numbers · no context only what an importer could read as text WALK THE DISK NOW always true; costs I/O find PATH -name … POSIX predicate language · composes with -exec fd PATTERN parallel walk · skips .gitignore + hidden smart-case regex on the path grep -rn PATTERN . POSIX, everywhere, single-threaded rg PATTERN parallel · same skip rules as fd · SIMD literals 20× faster than BSD grep -r here (§15) Cheap: names come free with the walk. Expensive: every candidate file must be opened and read.
The whole guide in one picture. Move up a row to trade freshness for speed; move right a column to trade speed for depth. Everything that goes wrong in file search is a tool being asked a question from a different cell.

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.

FailureLooks likeRoot cause
Stale indexYou saved it five minutes ago and locate shrugsThe index predates the file. locate's is rebuilt weekly.
Scope holeThe file exists, the tool is current, still nothingThe indexer was never allowed there: locate runs as nobody; Spotlight skips volumes with indexing off and anything under a .noindex folder.
Filtered outgrep -r finds it, rg does notrg and fd obey .gitignore, your global git excludes file, and hide dotfiles by default.
Wrong dialectThe pattern you copied off the internet returns nothing, or an errorFour regex dialects are in play on one machine (§8), and macOS grep is BSD, not GNU.
The wrong question

"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.

SymbolAs a shell globAs 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 dotany single character
[a-z]one character in the setone character in the set — the one place they agree
^ $literal charactersstart / end anchors
Matches againstthe 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.

ToolIts pattern argument is…Anchored?Case
find -namea globyes, whole basenamesensitive (-iname to relax)
find -patha globyes, whole pathsensitive
fda regex on the pathnosmart — sensitive only if you type a capital
fd -ga globyes, whole basenamesmart
locatea substring, or a glob if it contains meta­charactersnosensitive (-i to relax)
grepa BRE (-E for ERE, -F for literal)nosensitive (-i to relax)
rga Rust regexnosensitive-S to make it smart
rg -ga gitignore-style globpath-relativesensitive
mdfinda Spotlight query — words, not characterstoken-wiseinsensitive by default
What happens to your pattern between keyboard and tool A pipeline: what you type, then shell expansion of unquoted globs, then the argument vector the tool receives, then the tool's own matcher. Quoting stops the shell from consuming the pattern. UNQUOTED — THE SHELL EATS IT find . -name *.js what you typed zsh globs find . -name client.js retry.js the argv find actually receives find: retry.js: unknown primary or operator QUOTED — THE TOOL GETS THE PATTERN find . -name '*.js' quotes survive the shell no globbing find . -name *.js one argument, still a glob ./client.js ./retry.js The rule: if a pattern contains * ? [ ] or a space, quote it. Every time. There is no case where quoting a search pattern hurts.
zsh (the macOS default shell) aborts the whole command when an unquoted glob matches nothing — 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.
Two names, two identities

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

ToolPathShips with macOS?Implementation
find/usr/bin/findyesBSD find — not GNU findutils
grep, egrep, fgrep/usr/bin/grepyesBSD grep 2.6.0-FreeBSD (hard links to one binary)
locate/usr/bin/locateyesBSD locate; database job ships disabled
mdfind, mdls, mdutil, mdimport/usr/bin/yesSpotlight client tools
fdHomebrewnobrew install fd
rgHomebrewnobrew install ripgrep
ugrep, ggrepHomebrew or MacPortsnooptional 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
Apple silicon vs Intel

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
An alias is not a neutral wrapper

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.

Lab 1 Build the sandbox every later lab uses

4 minutes · disposable temp dir · no sudo · nothing outside the sandbox is touched

  1. 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 .
  2. Confirm the shape:
    find . -type f -not -path './.git/*' | sort
    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.)
  3. 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.
Checkpoint: 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.
Spotlight will not see this sandbox

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

PredicateMeansNote
-name '*.js'basename globanchored to the whole basename; -iname for case-insensitive
-path './build/*'full-path globmatched against the path as printed, so the leading ./ counts
-type f d lregular file / directory / symlink-type l is how you list symlinks without following them
-size -2smaller than 2 blocks (512 B)suffix it: -size +10M, -size -1k
-mtime +30modified more than 30 days ago-mmin for minutes; + older, - newer
-newer FILEmodified after FILE wasthe cheapest "since the last build" filter there is
-prunedo not descend into thisfaster than -not -path: it never enters the directory
-maxdepth Nstop at depth Non BSD find it must come before other predicates
-exec … {} +batch the matches into one commandsee 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:

FormProcessesSafe with spaces?Verdict
-exec cmd {} \;one per matchyescorrect but slow — fine for a handful
-exec cmd {} +batched, like xargsyesthe default choice
| xargs cmdbatchednobroken on any path with a space
-print0 | xargs -0 cmdbatchedyesuse 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
NUL or nothing

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 wantedGNU findutilsmacOS (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 outputnot availablefind -s .
Depth limitanywhere in the expression-maxdepth / -mindepth must precede other predicates
Delete matches-delete-delete exists, and is just as dangerous
GNU behaviour anywaybrew install findutilsgfind
# 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.

Lab 2 Read find as a boolean expression

6 minutes · uses $SB from Lab 1 · read-only

  1. Ask for JavaScript files:
    cd "$SB" && find . -name '*.js'
    Expect: 5 paths, including node_modules/left-pad/index.js and build/bundle.js — find has no opinion about your .gitignore.
  2. Prune the noise two ways and compare:
    find . -name '*.js' -not -path './node_modules/*' -not -path './build/*'
    find . \( -name node_modules -o -name build \) -prune -o -name '*.js' -print
    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.
  3. Break it on purpose, then fix it:
    cd src/api && find . -name *.js ; cd "$SB"
    Expect: find: retry.js: unknown primary or operator — the shell expanded the glob into two words. Quoting it fixes it.
  4. Prove the NUL problem to yourself:
    find . -name '*.txt' | xargs wc -c
    find . -name '*.txt' -print0 | xargs -0 wc -c
    Expect: four "No such file or directory" errors from the first, a clean two-file total from the second.
Checkpoint: you can state, without checking, why -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 defaultReveal 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-ufd --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
Misconception: "the modern tools all default to smart case"

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.

The argument order that catches everyone once

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.

Output order is not stable

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.

Lab 3 Find the file fd is hiding from you

5 minutes · uses $SB · read-only

  1. cd "$SB" && fd -e js
    Expect: 3 results, all under src/.
  2. Now count what a plain walk sees:
    find . -name '*.js' -not -path './.git/*' | wc -l
    Expect: 5.
  3. Ask fd to explain the gap by turning the filters off one at a time:
    fd -e js --no-ignore | wc -l   # → 5
    fd -e js --hidden    | wc -l   # → 3
    Expect: --no-ignore restores both files; --hidden restores neither. The exclusion was .gitignore, not dotfile-hiding.
  4. Ask git the same question directly — this is the diagnostic to remember:
    git check-ignore -v build/bundle.js
    Expect: .gitignore:1:build/ build/bundle.js — file, line number, and the rule that matched.
Checkpoint: when a file is missing from 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

How the locate database is built and queried A weekly launchd job runs locate.updatedb as root, which drops privileges to the user nobody, walks the filesystem excluding pruned paths and non-native filesystems, and writes a compressed database. The locate command then scans that database linearly. BUILD — SATURDAY 03:15, ONCE A WEEK com.apple.locate launchd daemon StartCalendarInterval Weekday 6 · 03:15 locate.updatedb starts as root, then su -fm nobody ← the scope limit lives here find -s / … -print prunes /private/tmp, /private/var/folders, Backups.backupdb, firmlinks locate.mklocatedb front + bigram compression → 9.05% of raw /var/db/locate.database 113,744,501 bytes · 11,281,033 filenames · owner nobody · mode 0444 QUERY — EVERY TIME YOU RUN IT locate PATTERN Linear scan of the whole file, substring match. 4.35 s here — an index, but not a fast one. Nothing is sorted for lookup.
Two facts in this picture do most of the damage: the walk runs as 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
Rebuilding by hand is a 10-plus-minute, full-disk walk

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
Misconception: "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:

VariableValue on macOS 15.6Consequence
SEARCHPATHS/one pass from the root
FILESYSTEMShfs ufs apfsexFAT, FAT32, NTFS, SMB and NFS mounts are skipped entirely
PRUNEPATHS/private/tmp /private/var/folders /private/var/tmp */Backups.backupdbyour mktemp -d sandbox and Time Machine backups are invisible
firmlinksread from /usr/share/firmlinksprevents every path appearing twice under /System/Volumes/Data
LOCATE_CONFIG/etc/locate.rcoverride 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
Substring, not glob-anchored

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.

Lab 4 Measure your own staleness window

3 minutes · read-only · no sudo · touches nothing

  1. Date the index and count what it holds:
    locate -S
    stat -f '%Sm' -t '%Y-%m-%d %H:%M' /var/db/locate.database
    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.
  2. Create a file and watch locate not care:
    touch ~/locate-staleness-probe.txt
    locate locate-staleness-probe
    fd -H locate-staleness-probe ~ -d 1
    Expect: locate prints nothing; fd prints the file immediately. This is the freshness axis of §1, in two commands.
  3. Probe the privacy boundary:
    locate -c "$HOME/Documents/"
    locate -c "$HOME/"
    Expect: 0 for Documents (mode 700), a large number for the home directory itself (mode 755).
  4. Clean up: rm ~/locate-staleness-probe.txt
Checkpoint: you can say out loud when your database was built, and name one directory it will never contain.
When 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 -P9), 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".

A folklore correction

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

FlagEffectNotes
-E / -F / -GERE / fixed strings / BREBRE is the default; -F is faster and safer for literals
-rrecurse into directoriesidentical to -R on macOS; does not follow symlinked dirs
-n / -H / -hline numbers / force filename / suppress filename-H matters when grepping exactly one file in a script
-l / -Lnames of files with / without matchesstops reading each file at the first hit — much faster
-ccount matching linesnot matches; two hits on one line count once
-oprint only the matched partthe entire reason to use grep in a pipeline
-A/-B/-C ncontext after / before / around-C1 is usually enough to read a hit
-i / -w / -xignore case / whole word / whole line-w beats writing \b…\b by hand
-vinvert the matchcomposes: grep -v ERROR | grep -c WARN
-qsilent; exit status is the answerthe correct form inside if
-e PAT / -f FILEpattern that starts with - / patterns from a file-f takes one pattern per line
--include / --exclude / --exclude-dirglob filters during recursionthe 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

StatusMeansVerified
0at least one line matchedprintf 'a\n' | grep a
1no lines matched — not an errorprintf 'a\n' | grep b
2an actual error: bad pattern, unreadable file, unknown flaggrep 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.

Lab 5 Make grep answer a yes/no question

5 minutes · uses $SB · read-only

  1. Count files versus count lines and notice they differ:
    cd "$SB"
    grep -rl 'TIMEOUT' . --exclude-dir=.git | wc -l
    grep -rc 'timeout' -i docs/notes.md
    Expect: 7 files, then docs/notes.md:2 — one file, two matching lines.
  2. Use the exit status, not the output:
    if grep -q 'TIMEOUT' docs/notes.md; then echo present; else echo absent; fi
    grep -q 'NOPE' docs/notes.md; echo "rc=$?"
    Expect: present, then rc=1.
  3. Watch -F save you from a regex you did not mean to write:
    printf 'a.b\naxb\n' | grep 'a.b'
    printf 'a.b\naxb\n' | grep -F 'a.b'
    Expect: two lines from the first, one from the second.
  4. Confirm your machine's grep really is BSD:
    grep -P '\d' /etc/hosts; echo "rc=$?"
    Expect: grep: invalid option -- P, then rc=2.
Checkpoint: you can predict the exit status of a 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.

DialectReached byEngineShape
BRE — POSIX basicgrep, grep -G, sedsystem regex(3)quantifiers and groups must be escaped to be operators
ERE — POSIX extendedgrep -E, egrep, find -E, awksystem regex(3)escape a metacharacter to make it literal — the modern convention
Rust regexrg, fdregex crate (finite automata)ERE-like plus Perl classes, minus anything needing backtracking
PCRE2rg -P, ugrep -P, ggrep -P, pcre2grepPCRE2 10.45, JIT enabledthe full Perl vocabulary, including lookaround
Which binary speaks which dialect by default

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 all
  • ggrep → BRE · ggrep -E → ERE · ggrep -P → PCRE2
  • ugrepERE by default (not BRE — pass -G for that) · ugrep -P → PCRE2
  • rg → Rust regex · rg -P → PCRE2
  • fd → 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.

Patterngrep
BRE
grep -E
ERE
rg
Rust
rg -P
PCRE2
a+literal +quantifierquantifierquantifier
a\+quantifierliteral +literal +literal +
cat|dogliteral |alternationalternationalternation
cat\|dogalternationliteral |literal |literal |
a{3}literal bracesintervalintervalinterval
a\{3\}intervalliteral bracesliteral bracesliteral braces
\dworksworksworksworks
\w, \s, \bworksworksworksworks
[[:digit:]]worksworksworksworks
\(ab\)\1backreferenceerror: invalid backreference numberparse errorPCRE2 compile error
(ab)\1error: invalid backreference numberbackreferenceparse errorbackreference
foo(?=bar)no match, silentlyerror: repetition-operator operand invalidparse error, with advicelookahead
Misconception: "macOS grep can't do \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.

Why 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

Regex dialect playground

Type a pattern once and see how each engine reads it. The construct verdicts come from the measured table above; the match results are computed in your browser with JavaScript's engine, which is a close but not perfect stand-in — the authority is always the tool itself.

colou?r a\+ cat|dog \d{3}-\d{4} (ab)\1 foo(?=bar) ^ERROR\b.*timeout

JavaScript is disabled, so the playground is inert — the measured truth table above contains the same information.

Writing patterns that survive the trip

GoalPortable spellingWhy
A digit[[:digit:]] or [0-9]POSIX classes work in all four dialects
One or moregrep -E and write +never write BRE by choice; -E costs nothing
A literal stringgrep -F / rg -Fno dialect at all, and faster
A whole word-wworks identically in grep and rg; no \b needed
Case-insensitive-ian inline (?i) works in rg only
Lookahead / backreferencerg -Pthe only PCRE2 on a stock-plus-Homebrew Mac
Lab 6 Break one pattern in four ways

7 minutes · pipes only · no files touched

  1. Set up an input you can hold in your head:
    export IN='aaa\na+b\ncat\ndog\nabab\nfoobar\n'
  2. Watch + flip meaning:
    printf "$IN" | grep    'a+'
    printf "$IN" | grep -E 'a+'
    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".
  3. Confirm the \d myth is a myth on your machine:
    printf 'abc123\nxyz\n' | grep -E '\d+'
    printf 'abc123\nxyz\n' | grep '\d'
    Expect: abc123 from both. If your macOS version prints nothing, that is worth knowing — the portable form [[:digit:]] always works.
  4. Meet all three failure messages:
    printf "$IN" | grep -E 'foo(?=bar)'
    printf "$IN" | rg       'foo(?=bar)'
    printf "$IN" | rg -P    'foo(?=bar)'
    Expect: grep: repetition-operator operand invalid · a multi-line rg parse error that names --pcre2 · then foobar.
  5. Backreferences swap sides:
    printf "$IN" | grep    '\(ab\)\1'
    printf "$IN" | grep -E '(ab)\1'
    printf "$IN" | rg       '(ab)\1'
    Expect: abab, abab, then a parse error saying backreferences are not supported.
Checkpoint: given a failing pattern you can name the dialect it was written for, and translate it, without guessing.

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

The filter stack between a directory tree and a ripgrep match A funnel: every file in the tree passes through gitignore-family rules, the global git excludes file, the hidden-file filter, the symlink filter and the binary detector before ripgrep will search its contents. Each stage names the flag that disables it. every file in the tree FILTER 1 .gitignore .ignore .rgignore off: -u / --no-ignore FILTER 2 core.excludesFile your GLOBAL gitignore applies in every repo off: --no-ignore-vcs FILTER 3 dotfiles .env, .config/… off: -. / --hidden FILTER 4 symlinks not followed off: -L / --follow FILTER 5 binary has a NUL byte off: -uuu / --text In the §3 sandbox: 13 files on disk → 8 files searched → 4 matches for TIMEOUT. grep -r found the same string in 7 files. The three extra were build/bundle.js and logs/app.log (filter 1) and .env (filter 2 — a global ~/.gitignore_global entry, not the project's .gitignore at all). Diagnose with: git check-ignore -v <path>
Filters 1 and 2 are the ones that surprise people, and filter 2 is the one that is invisible from inside the project — it lives in your global git config.

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
The .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

FlagEffect
--fileslist 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-listnamed file types; --type-add 'web:*.{html,css,js}' to define your own
-l / --files-without-matchfiles with / without a hit
-cper-file count of matching lines
-o and -r '$1'print only the match; rewrite it with capture groups
-A/-B/-Ccontext lines, same as grep
--sort pathdeterministic output — costs the parallelism, so use it only when you need it
--statsmatches, files searched, bytes, seconds — the honest profiler
--nullNUL-terminate filenames for xargs -0
-Pswitch to PCRE2 for lookaround and backreferences
--debugexplains 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/*
A config file makes your machine unlike everyone else's

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.

Lab 7 Account for every file ripgrep skipped

8 minutes · uses $SB · read-only

  1. Get the two populations:
    cd "$SB"
    rg --files | wc -l
    find . -type f -not -path './.git/*' | wc -l
    Expect: 8 and 13. Five files are being skipped.
  2. Name them:
    comm -13 <(rg --files | sed 's|^|./|' | sort) \
              <(find . -type f -not -path './.git/*' | sort)
    Expect: ./.env, ./.gitignore, ./build/bundle.js, ./logs/app.log, ./node_modules/left-pad/index.js.
  3. Attribute each one to a filter:
    git check-ignore -v build/bundle.js logs/app.log node_modules/left-pad/index.js .env
    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.
  4. Watch the count climb as you disable filters:
    rg --files | wc -l
    rg --files --no-ignore | wc -l
    rg --files -uu | wc -l
    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.
Checkpoint: for any missing file you can say which of the five filters removed it, and which flag brings it back.

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
Where these came from

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.

CommandBest of 3LinesFilesvs rg
ugrep -r 'func NewClient' ~/go129 ms88714.0× faster
ugrep -r --ignore-files …126 ms88714.1× faster
rg 'func NewClient' ~/go515 ms8871baseline
ag 'func NewClient' ~/go1,221 ms88712.4× slower
ggrep -rI 'func NewClient' ~/go2,100 ms88714.1× slower
ack 'func NewClient' ~/go5,453 ms887110.6× slower
grep -rI 'func NewClient' ~/go (BSD)10,169 ms887119.7× slower
The ugrep result is real, not an artefact

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

ToolIts pitchVerdict, having run it
ugrepA 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.
agThe 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.0Perl, 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:

CommandFilesSkips
rg -l TIMEOUT .4.gitignore + global excludes + dotfiles
ugrep -r -l --ignore-files TIMEOUT .4same four files as ripgrep — identical set
ugrep -r -l TIMEOUT .6dotfiles only — no gitignore awareness by default
ack -l TIMEOUT .7VCS directories, but not .gitignore and not dotfiles
ggrep -rl TIMEOUT . --exclude-dir=.git7nothing but what you name
Two 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.

Revised opinionated default

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.

A name collision worth knowing

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 doorOpens withBest atWeakness
Spotlight menuSpace — "Show or hide the Spotlight search field"Launching, converting, calculating, and finding one known document fastRanked, truncated, and mixes files with apps, mail, web suggestions and definitions
Finder searchF in Finder, or Space from anywhereBrowsing a result set: sort it, preview it, add criteria rows, act on many filesThe criteria UI hides the real query behind pop-up menus
Smart FolderN, or File > New Smart FolderA saved, self-updating query you can keep in the sidebarNothing — 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
The workflow this unlocks

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.

Seeing exactly what the Spotlight menu would do

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.

Lab 8 Round-trip a query from Finder to the shell

6 minutes · creates one file in ~/Library/Saved Searches · reversible

  1. In Finder press N for a new Smart Folder.
  2. 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.
  3. Read back what the GUI wrote:
    plutil -p ~/Library/Saved\ Searches/Recent\ PDFs.savedSearch | grep -m1 RawQuery
    Expect: a RawQuery line containing kMDItemContentTypeTree and a kMDItemFSContentChangeDate comparison against a $time.* function — Apple's own generated query, in the syntax of §13.
  4. Run that query yourself:
    mdfind '<paste the RawQuery value here>' | head
    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.
  5. Undo: delete the Smart Folder from the Finder sidebar, or rm ~/Library/Saved\ Searches/Recent\ PDFs.savedSearch.
Checkpoint: you have a repeatable way to discover Spotlight attribute names without memorising any of them.

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.

Spotlight's indexing pipeline and its query clients File system events feed mds, which dispatches mdworker processes running importer plugins to extract text and metadata into a per-volume index store. mdfind, Finder and the Spotlight menu all query that same store. WRITE PATH — HAPPENS WHEN A FILE CHANGES FSEvents the kernel notices a file was written mds metadata server; schedules the work mdworker · mdworker_shared sandboxed; loads one of the 27 *.mdimporter plugins (mdimport -L) /System/Volumes/Data/.Spotlight-V100 one index per volume · root-owned, mode 0700 kMDItem* attributes + extracted text WHAT THE IMPORTER STORED IS ALL YOU CAN EVER SEARCH A PDF becomes kMDItemTextContent + kMDItemAuthors + kMDItemNumberOfPages. A .go source file has no declared type at all — kMDItemContentType is a generated "dyn.ah62d4rv4ge80s52" — yet its text is still indexed by the plain-text fallback. Check with mdls. READ PATH — THREE CLIENTS, ONE STORE mdfind / mdls Finder ⌘F Spotlight menu ⌘Space Access is enforced at query time, so you only see what you may read.
The index is written by plugins, not by the search command. Everything 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:

CommandFiles returnedWhat it actually asked
mdfind -onlyin ~/go 'func NewClient'726documents containing the word func and the word NewClient, anywhere, in any order
mdfind -onlyin ~/go '"func NewClient"'84documents containing that phrase, case-insensitively
mdfind -onlyin ~/go "kMDItemTextContent == '*func NewClient*'c"85the same, spelled as a raw query
rg -uu -l 'func NewClient' ~/go71files 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:

SetExampleWhy
14 files mdfind found and rg did notx/crypto/ssh/handshake.goit contains newClient — lowercase. Spotlight is case-insensitive; rg was not.
1 file rg found and mdfind did notcells/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:

QueryMatch?Rule it demonstrates
mdfind NewClientyesa bare word is a token prefixmdfind -interpret shows why: it expands to search*
mdfind ewClientnoprefix only. Spotlight never matches the middle of a token.
mdfind NewClientsPoolXnothe prefix runs the other way — your term must be a prefix of the token, not vice versa
mdfind 'alpha NewClient'yesunquoted words are separate terms, each prefix-matched
mdfind '"alpha NewClient"'noquoting turns off prefix matching. A phrase needs exact whole tokens.
mdfind "kMDItemTextContent == '*ewClient*'c"yesa raw query with leading and trailing * is a genuine substring search — the escape hatch when you need one
Reading the table sideways

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 rg14).

Misconception: "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

HoleCheck it withFix
Whole volume not indexedmdutil -a -ssudo mdutil -i on /Volumes/NAME
Folder name ends in .noindexls -d *.noindexrename it — this one genuinely works
Spotlight Privacy listSystem Settings → Siri & Spotlight → Spotlight Privacy…remove the entry
File type has no importer, or an importer that stores no textmdls -name kMDItemTextContent FILEnothing to fix — use rg for that file
Index is behindmdutil -s /, then waitmdimport -r PATH to force one path
Index is corruptresults are wrong in ways nothing else explainssudo 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.

Finding: .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.

Lab 9 Build an indexed sandbox and watch Spotlight think

9 minutes · creates ~/spotlight-lab · deletes it at the end · no sudo

  1. Build it in $HOME, because /tmp is 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"
  2. Search by content and by name:
    mdfind -onlyin "$SL" Zylophantic 2>/dev/null
    mdfind -onlyin "$SL" -name memo 2>/dev/null
    Expect: three files from the first (the .rtf too — Spotlight read inside it, which grep could not have done usefully), one from the second.
  3. Walk the matching rule yourself — this is the step to do slowly:
    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
    Expect: 1, 0, 0, 1 — prefix yes, mid-token no, quoted phrase needs the exact token.
  4. Then find the escape hatch:
    mdfind -onlyin "$SL" "kMDItemTextContent == '*ewClient*'c"
    rg -l ewClient "$SL"
    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.
  5. Compare a .noindex folder with the marker file:
    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
    Expect: marker/b.txt appears; hidden.noindex/a.txt does not. Only the folder-suffix mechanism worked on macOS 15.6.
  6. Read what the index actually stored:
    mdls "$SL/report.txt" | head -12
    mdls -name kMDItemTextContent "$SL/report.txt"
    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.
  7. Clean up: rm -rf ~/spotlight-lab
Checkpoint: you can explain, to someone else, why 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

PieceSyntaxExample
Comparison== != < > <= >=kMDItemFSSize > 5000000
Boolean&& || and parenthesesA && (B || C)
Value modifiersc 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'"
Escape $ 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.

Files macOS has no type for get a generated one

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 kMDItemFSNameHitsReading
'report.txt'1exact
'report*'1trailing star — reliable
'*port*'1surrounded — reliable
'*t.txt'1leading — reliable
'report.*'1star as a whole dot-separated component
're*rt.txt'1star inside a component, no trailing literal component
'repor*.txt'0fails — star mid-component with a literal .txt after it
're*.txt'0fails — same shape
'?eport.txt'0? never worked in any position
Doc drift, and the safe rule

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

Spotlight query builder

Compose a raw query from attributes that exist, with the modifiers and wildcard rules this guide measured. Copy the command out and run it.

mdfind -onlyin ~/Documents "kMDItemFSName == '*.pdf'c" 2>/dev/null

JavaScript is disabled — the syntax tables above contain the same rules.

A working vocabulary

QuestionQuery
Screenshots from this weekmdfind "kMDItemIsScreenCapture == 1 && kMDItemContentCreationDate >= \$time.this_week"
Big PDFs in Documentsmdfind -onlyin ~/Documents "kMDItemContentType == 'com.adobe.pdf' && kMDItemFSSize > 5000000"
Anything tagged Redmdfind "kMDItemUserTags == 'Red'cd"
Downloaded from the webmdfind "kMDItemWhereFroms == '*github*'c"
Images taller than 2000 pxmdfind "kMDItemPixelHeight > 2000"
Video longer than 10 minutesmdfind "kMDItemDurationSeconds > 600"
Everything touched today, livemdfind -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.

Lab 10 Answer a question no walker can answer

7 minutes · read-only · your real home directory

  1. Discover the vocabulary rather than recalling it:
    mdls "$(mdfind -onlyin ~ 'kind:pdf' 2>/dev/null | head -1)" | grep -E 'Author|Page|Size|Title'
    Expect: kMDItemAuthors, kMDItemNumberOfPages, kMDItemFSSize, kMDItemTitle — attributes that exist only because a PDF importer wrote them.
  2. Ask a four-predicate question:
    mdfind -onlyin ~ 'kMDItemContentTypeTree == "com.adobe.pdf" &&
            kMDItemFSSize > 1000000 &&
            kMDItemContentModificationDate >= $time.today(-90)' 2>/dev/null | head
    Expect: recent, largish PDFs. Note single quotes around the whole query so $time survives.
  3. Time the nearest walking equivalent:
    time fd -u -e pdf --size +1m --changed-within 90d . ~ | wc -l
    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.

    Then 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.

  4. Confirm the wildcard rule on your own files:
    mdfind -onlyin ~ "kMDItemFSName == '*.pdf'" 2>/dev/null | wc -l
    mdfind -onlyin ~ "kMDItemFSName == 'r*.pdf'" 2>/dev/null | wc -l
    Expect: a healthy number, then 0 — the mid-component star with a literal component after it, exactly as in the table above.
Checkpoint: you can write a three-predicate raw query from memory, and you know which two documented features to avoid.

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 flagTool
-print0find
-0 / --print0fd, locate, mdfind
--nullrg, grep (with -l)
-0xargs, on the receiving end — always
Lab 11 Chain the index to the walker

6 minutes · uses $SB and ~/spotlight-lab · read-only

  1. Two tools, one question:
    SL=~/spotlight-lab
    mdfind -0 -onlyin "$SL" Zylophantic 2>/dev/null | xargs -0 rg -H -n -i quarterly
    Expect: report.txt and memo.rtf with line numbers. Spotlight picked the candidates including inside the RTF; ripgrep located the line.
  2. Show yourself why -H matters:
    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
    Expect: 3:timeout matters. from the first — no filename, because only one file was passed. docs/notes.md:3:timeout matters. from the second.
  3. Count matches per file without writing a loop:
    rg --json -uu TIMEOUT . | jq -r 'select(.type=="match")|.data.path.text' | sort | uniq -c
    Expect: seven paths each with a count of 1. (Needs jq — brew install jq.)
Checkpoint: you reach for -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

CommandBest of 3ResultsWhat it really did
fd -HI --glob '*.pb.go' ~/go121 ms3,888parallel walk, glob anchored to the basename
mdfind -onlyin ~/go -name .pb.go209 ms3,891index lookup; -name is a substring match
find ~/go -name '*.pb.go'629 ms3,888single-threaded walk
locate '.pb.go'4,352 ms3,952linear scan of 11.2 M names across the whole disk
The 3 extra results are the interesting part

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

CommandBest of 3Resultvs rg
ugrep -r 'func NewClient' ~/go129 ms88 lines / 71 files4.0× faster
mdfind -onlyin ~/go 'func NewClient'321 ms726 filesa different question entirely
rg 'func NewClient' ~/go515 ms88 lines / 71 filesbaseline
rg -uu 'func NewClient' ~/go691 ms88 lines1.3× slower, same answer
ag 'func NewClient' ~/go1,221 ms88 lines2.4× slower
ggrep -rI 'func NewClient' ~/go2,100 ms88 lines4.1× slower
ack 'func NewClient' ~/go5,453 ms88 lines10.6× slower
grep -rI 'func NewClient' ~/go (BSD)10,169 ms88 lines19.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.

What these numbers cannot tell you
  • 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.

Decision tree for choosing a macOS file-search tool Start by asking whether you are matching file contents or a name and metadata. For contents, choose ripgrep for exact text in a project, or mdfind when the documents are indexed and you only need candidates. For names, choose fd inside a known tree, mdfind for metadata questions, and locate only for system files you cannot place. What are you matching? text INSIDE files a NAME or a property Do you need the exact line? yes no, just the files rg PATTERN regex · line numbers · context skips .gitignore + hidden add -uu when it finds too little mdfind 'words' indexed · reads PDF, RTF, docx token prefixes, not substrings pipe into rg for the line Do you know which tree it is in? yes no fd PATTERN DIR always current · fast · smart case use find for -exec pipelines and for POSIX portability mdfind -name X whole machine, indexed the only one that knows size, kind, tags, author, EXIF Third question, only if the first answer came back empty: was the file created after the index was built? locate → up to 7 days stale, and blind to anything mode 700. · mdfind → seconds behind, blind to unindexed volumes. · fd / rg / find → never stale. The one-line default, when you cannot be bothered to think: rg PATTERN for text · fd PATTERN for names · mdfind -onlyin ~ 'kind:pdf …' for properties
The third question is the recovery path, not the entry point. Ask it only when a search that should have worked came back empty.

The decision matrix

You want…UseBecause
A string in the project you are standing inrg PATTERNfast, respects .gitignore, gives line numbers
…including ignored and hidden filesrg -uu PATTERNthe filters, not the speed, are what changed the answer
A string in one known filegrep -n PATTERN fileno reason to reach further
A regex with lookahead or a backreferencerg -P PATTERNalso ugrep -P / ggrep -P if installed (§10)
A string inside a zip, tar or 7zugrep -z -r PATTERN ARCHIVEthe only tool here that searches archive members
A string you might be misspellingugrep -Z2 PATTERNfuzzy matching by edit distance; nothing else here has it
Raw throughput over a very large treeugrep -r --ignore-files4× faster than rg here, identical results (§15)
A filename inside a tree you can namefd PATTERN DIRcurrent, parallel, smart case
Filenames, then act on each onefind … -exec … {} + or fd … -Xbatching without an xargs quoting bug
A file by size, kind, tag, author, EXIF, durationmdfind "kMDItem… "nothing else has that metadata at all
A document whose contents you half-remembermdfind 'phrase'it read inside your PDFs and Pages files; grep cannot
The system file you cannot placelocate NAMEone query covers /usr, /opt, /Library, every SDK
Something you created in the last hourfd or rgboth indexes may be behind; a walk cannot be
Anything inside ~/Documents or ~/Librarynot locatemode 700 means nobody never saw it
Anything in $TMPDIRnot locate, not mdfindboth prune /private/tmp and /private/var/folders
Portability to Linux in a scriptfind + grep, or ggrepthey are the only two guaranteed to be there

Translation table, if you came from Linux

Linux instinctmacOS moveWhy it is different
grep -P '\d+'rg -P, ugrep -P or ggrep -PBSD 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-extendedfind -E . -regex …the flag moves in front of the path
updatedb then locatesudo /usr/libexec/locate.updatedbweekly job, runs as nobody, ships disabled
locate finds everythingmdfind finds everythingon macOS the whole-machine index is Spotlight's, not locate's
grep -R follows symlinksit does not, here — use ggrep -R-r and -R are one flag on BSD grep; measured in §7
ls -l tells you everythingmdls FILEmacOS 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.

Capstone Find the thing, then explain the tools' disagreement

25 minutes · builds and removes ~/capstone-lab · read-only against your real files · no sudo

Phase 1 · Build a corpus Spotlight can see

  1. 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
    Expect: no output. Five files across three directories, one of which Spotlight will refuse to index.

Phase 2 · Ask each tool the same question and predict the answers first

  1. Write down your prediction for each, then run them:
    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"
    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.
  2. Attribute each absence to a mechanism:
    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
    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.

Phase 3 · Use the index to narrow, the walker to answer

  1. mdfind -0 -onlyin "$CL" Blorbtech 2>/dev/null | xargs -0 rg -H -n 'net 30'
    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.
  2. Now the metadata question no walker can answer:
    mdfind -onlyin "$CL" 'kMDItemContentTypeTree == "public.rtf" && kMDItemFSSize > 100' 2>/dev/null
    mdls -name kMDItemKind -name kMDItemFSSize "$CL/invoices/inv-0412.rtf"
    Expect: …/invoices/inv-0412.rtf kMDItemFSSize = 398 kMDItemKind = "Rich Text Document"

Phase 4 · Take it to your real disk

  1. 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
  2. Ask the same question three ways and time all three:
    time mdfind -count -onlyin ~ 'YOURTERM' 2>/dev/null
    time rg -l YOURTERM ~ 2>/dev/null | wc -l
    time locate -c YOURTERM
    Expect: three different counts and three very different times. If you can explain all three gaps without re-reading the guide, you are done.
  3. Clean up everything this guide created:
    rm -rf ~/capstone-lab ~/spotlight-lab "$SB"
Checkpoint: for every file that appeared in one tool's output and not another's, you can name the mechanism — .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

SymptomLikely causeFix / check
rg finds nothing, grep -r finds itan ignore rulegit check-ignore -v PATH; then rg -uu
rg --hidden still misses ityour global gitignore (core.excludesFile)git config --get core.excludesFile; rg --no-ignore-vcs
rg misses a lowercase/uppercase variantrg is case-sensitive by defaultrg -i or rg -S
fd 'ABC' finds nothing, fd 'abc' workssmart case: a capital made it sensitivefd -i
fd -e pdf ~ silently returns nothing~ was taken as the patternfd . ~; read stderr, fd explains it
locate misses a file you made todaythe database is up to a week oldstat -f '%Sm' /var/db/locate.database; use fd
locate never finds anything in ~/Documentsthe indexer runs as nobody; mode 700 blocks itstructural — use mdfind or fd
locate: /var/db/locate.database: No such file or directorythe launchd job ships disabledsudo launchctl load -w /System/Library/LaunchDaemons/com.apple.locate.plist
mdfind misses a file on an external driveindexing off for that volumemdutil -a -s; sudo mdutil -i on /Volumes/NAME
mdfind misses a whole folderfolder name ends in .noindex, or it is in Spotlight Privacyrename it; System Settings → Siri & Spotlight → Spotlight Privacy…
mdfind misses part of a wordtoken prefix matching — never mid-token"kMDItemTextContent == '*frag*'c", or pipe to rg
A quoted mdfind phrase returns nothingquoting disables prefix matching; tokens must match exactlydrop the quotes, or use the raw wildcard form
Nothing finds anything in $TMPDIR/private/var/folders is pruned from both indexesuse fd/rg, or work under $HOME
Search misses files inside a symlinked directorynothing here follows symlinks by defaultfd -L · rg -L · find -L · ggrep -R (BSD grep -R will not)

Errors and refusals

MessageCauseFix
find: retry.js: unknown primary or operatorunquoted glob expanded by the shellquote the pattern: -name '*.js'
zsh: no matches found: *.jssame, but nothing matched, so zsh abortedquote it
find: -printf: unknown primary or operatorGNU-only predicate on BSD find-print, or brew install findutilsgfind
grep: invalid option -- PBSD grep has no PCRErg -P · ugrep -P · ggrep -P · pcre2grep
grep: repetition-operator operand invalidlookahead written for PCRE, run through ERErg -P
grep: invalid backreference number(ab)\1 in BRE, or \(ab\)\1 in EREmatch the escaping to the dialect (§8)
rg: regex parse error … look-around … is not supportedRust regex has no backtrackingrg -P, exactly as the message says
rg: regex parse error … repetition operator missing expressionyou passed a glob where a regex was expectedrg -g '*.js' PATTERN
find: …: Operation not permittedTCC, not file modes — ~/Library/Mail, TCC.db, and friendsgrant the terminal Full Disk Access, deliberately
wc: ./with: open: No such file or directorya path with a space crossed a pipe without NUL separation-print0/-0 and xargs -0
mdfind[…] [UserQueryParser] Loading keywords…informational stderr chatter2>/dev/null; raw kMDItem queries do not print it
Query with $time.today silently matches everythingzsh expanded $time inside double quotessingle-quote the query, or write \$time

Too many results, or the wrong ones

SymptomCauseFix
mdfind word returns thousandsbare words are token prefixes, across every attributequote the phrase; add -onlyin; use a raw kMDItem… query
locate name returns Xcode SDK copies of everythingsubstring match over the entire diskanchor it: locate '/usr/bin/name'
grep -r is drowning in node_modulesgrep has no ignore rulesuse rg, or --exclude-dir
Results differ every runfd and rg walk in parallelrg --sort path · fd --threads 1 · pipe to sort
A file appears twicea hard link, or a firmlinked path under /System/Volumes/Datals -li to compare inode numbers

Performance

SymptomCauseFix
grep -r takes minutessingle-threaded, opens everythingrg is 20× faster here, ugrep -r 79× (§15)
rg is slow on one projecta giant vendored or build directory is not ignoredadd it to .ignore; check with rg --files | wc -l
ugrep returns more than rg didit does not read .gitignore unless toldugrep -r --ignore-files — lands on ripgrep's exact set (§10)
A pattern that was literal under grep becomes a quantifier under ugrepugrep defaults to ERE, not BREugrep -G, or write the pattern for ERE
locate takes five secondslinear scan of an 11-million-name fileexpected; use fd if you know the tree
mdfind is slow right after a big copySpotlight is still importingmdutil -s /; wait, or mdimport -r a specific path
Fans spin up for hours after plugging in a drivefirst-time indexing of that volumeexpected 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 quote
  • find . -type f -mtime -7
  • find . -size +10M
  • find . -newer FILE
  • find . \( -name build -o -name node_modules \) -prune -o -print
  • find . -name '*.tmp' -exec rm {} +
  • find -E . -regex '.*/(a|b)\.js'
  • find -s . — sorted (BSD only)
  • no -printf; -maxdepth goes first

fd

  • fd PATTERN [DIR] — one arg = pattern
  • fd . ~ — match-all in a directory
  • fd -e md -e txt
  • fd -t f · -t d · -t l · -t x · -t e
  • fd -H hidden · -I no-ignore · -u both
  • fd --changed-within 2weeks
  • fd -S +1m — by size
  • fd -x cmd {} · -X cmd
  • smart case; -s to force sensitive

locate

  • locate NAME — substring, whole disk
  • locate '/usr/bin/md*' — glob if meta
  • locate -i · -c · -l N · -0
  • locate -S — database stats
  • stat -f '%Sm' /var/db/locate.database
  • sudo /usr/libexec/locate.updatedb
  • weekly · runs as nobody · skips mode 700
  • skips exFAT/SMB/NFS and /private/tmp

grep (BSD)

  • grep -rn PAT .
  • grep -E ERE · -F literal · -G BRE
  • grep -i -w -v -c -l -o
  • grep -C2 PAT file
  • grep -q PAT file — status only
  • grep -rn --include='*.js' PAT .
  • exit 0 match · 1 no match · 2 error
  • no -P; -r = -R
  • \d \w \s \b do work here

ripgrep

  • rg PATTERN — case-SENSITIVE
  • rg -S smart · -i insensitive
  • rg -uu — ignore rules + hidden off
  • rg -t js · -g '*.md' · -g '!vendor/*'
  • rg --files — what it would search
  • rg -l · -c · -o · -r '$1'
  • rg -A2 -B2 · --sort path
  • rg -P — lookaround, backrefs
  • rg --stats · --debug · --json

mdfind

  • mdfind -onlyin DIR 'words'
  • mdfind -name NAME — substring
  • mdfind -count · -0 · -live
  • mdfind -attr kMDItemFSSize QUERY
  • mdfind -interpret 'x' — as the menu would
  • mdls FILE — the attribute vocabulary
  • mdutil -a -s · mdimport -r PATH
  • always 2>/dev/null in scripts
  • bare word = token prefix; quotes = exact tokens

Spotlight query syntax

  • == != < > <= >= · && || ()
  • 'value'c case · 'value'd accents · 'value'w words
  • * at the ends only; ? never works
  • $time.today(-7), $time.this_week
  • single-quote the query so $time survives
  • InRange() is documented and broken — use two comparisons
  • kMDItemContentTypeTree > kMDItemContentType

ugrep & ggrep

  • ugrep -r PAT DIRERE by default
  • ugrep -G — BRE, the true drop-in
  • ugrep -r --ignore-files — obey .gitignore
  • ugrep -P · ggrep -P — PCRE2
  • ugrep -z -r PAT file.zip — inside archives
  • ugrep -Z2 PAT — fuzzy, edit distance 2
  • ug --query — interactive TUI
  • ggrep -R — actually follows symlinks
  • ugrep -r --stats — files/lines scanned

Diagnostics

  • git check-ignore -v PATH
  • rg --files | wc -l
  • rg --debug PATTERN
  • mdutil -a -s
  • mdls -name kMDItemTextContent FILE
  • locate -S
  • type -a grep
  • ls -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 match a.txt.bak; locate '.txt' is unanchored and will. (§2)
BRE — basic regular expression
POSIX's older regex dialect, the default for grep and sed. Quantifiers and groups are literal unless escaped: \+, \|, \{3\}, \(…\). (§8)
ERE — extended regular expression
POSIX's modern dialect, reached with grep -E, egrep, find -E or awk. 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. rg and fd honour it, which is why a file can be invisible to them with nothing in the project's own .gitignore. (§9)
An APFS link that joins the read-only System volume to the writable Data volume, so /Users and /System/Volumes/Data/Users are the same directory. locate.updatedb prunes 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, kMDItemTextContent and hundreds more. List them for any file with mdls. (§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_index marker 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 -0 on one side, xargs -0 on 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 … -prune never opens it at all, which is why it beats filtering the results with -not -path. (§4)
RawQuery
The key inside a Finder saved search (.savedSearch plist) holding the Spotlight query string the GUI generated. Readable with plutil -p and runnable verbatim in mdfind. (§11)
smart case
Case-insensitive matching that turns itself off when the pattern contains an uppercase letter. fd does this by default; rg requires -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. mdfind matches 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. kMDItemContentTypeTree matches anywhere in that hierarchy; kMDItemContentType only 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 @ in ls -l, listed by xattr -l, and indexed by Spotlight as kMDItem* attributes. Invisible to grep. (§2)

21 · Index

Symbols
-0§4, §14
--exclude-dir§7
--files§9
--hidden, -.§9
--no-ignore-vcs§5, §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§13
A–C
ack → §10
archive search → §10
ag (silver searcher) → §10
alias, grep → §3
anchoring → §2
Apple silicon paths → §3
backreference → §8
benchmarks → §15
binary detection → §9
BRE → §8
BSD vs GNU find → §4
BSD vs GNU grep → §7
case sensitivity → §5, §9
check-ignore§5, §9
content types (UTI) → §13
D–F
decision matrix → §16
diacritics (d) → §13
dotfiles → §9
dyn. UTIs → §13
egrep → §7
ERE → §8
exit status → §7
extended attributes → §2
fd§5
firmlinks → §6
find§4
Finder search → §11
Full Disk Access → §12
fzf§14
G–L
ggrep (GNU grep) → §10, §7
gitignore chain → §9
globs vs regex → §2
grep§7
hard links → §2
InRange() (broken) → §13
inodes → §2
jq with --json§14
kMDItem*§13
launchd, com.apple.locate → §6
locate§6
locate.updatedb§6
lookahead → §8
M–P
mdfind§12
mdimport§12
mdls§13
mdutil§12
.metadata_never_index§12
.noindex§12
nobody, locate runs as → §6
NUL separation → §4
PCRE2 → §8
parallel walk order → §5
plutil§11
PRUNEPATHS → §6
Q–S
quoting patterns → §2
RawQuery§11
regex dialects → §8
RIPGREP_CONFIG_PATH§9
rg (ripgrep) → §9
Rust regex crate → §8
fuzzy matching → §10
.savedSearch§11
set -e and grep → §7
Smart Folder → §11
smart case → §5
Spotlight menu → §11
Spotlight Privacy → §12
staleness → §6
symlinks → §2, §7
T–Z
TCC → §12
token matching → §12
troubleshooting → §18
type -a§3
ugrep§10, §8
ugrep -z (archives) → §10
ugrep -Z (fuzzy) → §10
UTI → §13
volumes, unindexed → §12
wildcards, Spotlight → §13
xargs -0§4
zsh no matches found§2

22 · 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.

Score: 0 learned · 0 review · 0/17 answered

Q1Name the two axes that place every tool in this guide, and put mdfind on both.
Index vs live walk, and name/metadata vs contents. 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)
Q2Why does find . -name *.js fail differently depending on what is in the current directory?
The shell expands the unquoted glob first. Two matches become two words and find reports unknown primary or operator; zero matches make zsh abort with no matches found. Quote it. (§2)
Q3Which of fd and rg is smart-case by default?
fd. rg is case-sensitive by default and needs -S to become smart. This is the most common cause of a ripgrep search coming back emptier than expected. (§5, §9)
Q4rg --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)
Q5locate never finds anything in ~/Documents. Is this a bug, and can you fix it by rebuilding?
Neither. 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)
Q6How stale can locate's answer be, and how do you check?
Up to seven days: the launchd job runs Saturdays at 03:15. Check with stat -f '%Sm' /var/db/locate.database or locate -S. (§6)
Q7Does macOS 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)
Q8In which dialect does a\+ mean "one or more a", and in which does it mean a literal plus?
Quantifier in BRE (plain 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)
Q9Why does ripgrep refuse lookahead, and what does it offer instead?
The Rust 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)
Q10A file contains the single word NewClientsPool. Which of mdfind NewClient, mdfind ewClient and mdfind '"alpha NewClient"' match it?
Only the first. Bare words are token prefixes; mid-token substrings never match; quoting turns prefix matching off so a phrase needs exact whole tokens. For a real substring, use kMDItemTextContent == '*ewClient*'c. (§12)
Q11Name three reasons mdfind can be blind to a file that certainly exists.
Any of: the volume has indexing disabled (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)
Q12Where does a Finder Smart Folder keep its query, and what can you do with it?
In the 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)
Q13Two documented Spotlight query features do not work on macOS 15.6. Which, and what do you write instead?
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)
Q14Why is find … | xargs a bug, and what are the two correct forms?
A newline or space in a path splits into several arguments — silent, and destructive if the command deletes. Use -exec cmd {} +, or -print0 | xargs -0. NUL is the only byte a path cannot contain. (§4)
Q15Why did locate return 3,952 results where find -name '*.pb.go' returned 3,888?
Scope and anchoring. 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)
Q16Write the universal pipeline for "find the line, in documents Spotlight can identify."
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)
Q17Seven grep-shaped tools searched the same 39,798-file tree with a 79-fold spread in time. How many different answers did they return?
One. All seven found 88 matching lines in the same 71 files — 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

Provenance

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

Manual pages

The tools

  • ripgrep — user guide Best used for: the filtering model, the -u/-uu/-uuu ladder, file types, globs and RIPGREP_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/-X placeholder syntax, and the design rationale for the default exclusions.
  • Rust regex crate — 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 -P unlocks — 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, --bind and reload options the §14 shell functions depend on.

Standards and background