TechEarl

Recursive grep: Search, Exclude Paths, and List Filenames

Search a directory with grep -r, filter files and directories, list matching or nonmatching filenames, and handle Linux/macOS differences safely.

Ishan Karunaratne⏱️ 7 min readUpdated
Share thisCopied
Recursive grep commands for searching directories, filtering files, and listing filenames on Linux and macOS.
bash
grep -rnF -e 'needle' ./src

This searches files below ./src, prints matching lines with line numbers, and treats needle as literal text. Drop -F when the pattern should be a regular expression. I pass the search directory explicitly so the command is easy to review and behaves predictably across grep implementations.

The same recursive search works with current GNU grep on Linux and Apple's grep on macOS. The differences that matter are symlink handling and NUL-separated filename output, covered below.

The one-liner

Try it with your own values

Set the search path and pattern, then choose the command for your operating system.

bash· Linux (GNU)
grep -rn -e ':pattern' ':search_path'

These examples interpret the pattern as a regular expression. For literal text, use grep -F or PowerShell's -SimpleMatch. Values containing a single quote need shell-appropriate escaping before you run the displayed command.

PowerShell recursion belongs to Get-ChildItem, not Select-String. -CaseSensitive makes its matching closer to grep's default. Reading a protected file can still fail; absence from the results is not proof that an unreadable file contains no match.

Recursive grep flags

FlagResult
-rRecurse into directories
-nInclude the matching line number
-HAlways include the filename, even with one input file
-iIgnore letter case
-FMatch fixed strings rather than regular expressions
-EUse extended regular expressions
-ISkip files detected as binary
-lPrint filenames containing a match
-LPrint filenames with no matching line
-e PATTERNSupply a pattern explicitly, including one beginning with -
bash
grep -rnI -e 'TODO' .
grep -rnE -e 'error|warning' ./logs
grep -rniF -e 'connection refused' ./logs

A search can return exit status 1 simply because nothing matched. Status 2 on GNU grep signals an error. Do not treat both outcomes as the same thing in a script.

Search only certain file types

Quote filename globs so the shell leaves them for grep:

bash
grep -rn --include='*.ts' --include='*.tsx' -e 'useEffect' .
grep -rn --include='*.log' -e 'ERROR' ./logs

Current macOS grep supports --include, --exclude and --exclude-dir. They are extensions rather than POSIX guarantees, so check the installed manual before relying on them in a script for an unknown host. There is no need to install GNU grep just to use these three flags on a current Mac.

Exclude directories and files

bash
grep -rn --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist \
  --exclude='*.min.js' --exclude='*.map' -e 'deprecated' .

Use directory-name patterns for broad exclusions such as node_modules. GNU grep matches recursively discovered directories by their basename; a value such as frontend/node_modules is not a portable path-specific exclusion. find -prune gives explicit control over an exact path.

Repeated flags work in a POSIX shell. Bash and zsh also support the shorter brace-expansion form, but /bin/sh does not guarantee it:

bash
grep -rn --exclude-dir={node_modules,.git,dist} -e 'deprecated' .

Read exclude patterns from a file

GNU grep accepts a file of exclusion globs:

text
*.min.js
*.map
*.lock

Save those lines as .grepignore, then run:

bash
grep -rn --exclude-from=.grepignore --exclude-dir=node_modules -e 'deprecated' .

This is a list of filename globs, not a .gitignore parser. Keep directory exclusions separate and do not assume negated Git ignore rules work. --include-from is not a corresponding GNU grep option; use repeated --include flags or select files with find. Check platform support for --exclude-from itself before sharing this command across systems.

Select files with find and prune

For an exact directory exclusion, prune the path before searching:

bash
find . -path './frontend/node_modules' -prune -o \
  -type f -name '*.ts' -exec grep -nH -e 'deprecated' {} +

For every directory with either of two names:

bash
find . -type d \( -name node_modules -o -name .git \) -prune -o \
  -type f -exec grep -nH -e 'deprecated' {} +

-exec ... {} + passes filenames as separate arguments, including spaces and newlines, and avoids an empty xargs invocation when no files are selected. A leading ./ on the paths also prevents a filename beginning with a hyphen from becoming a command option. See exclude directories with find for more selection examples.

List only filenames with grep -l

bash
grep -rl -e 'deprecated' .
grep -rl --include='*.ts' --exclude-dir=node_modules -e 'deprecated' .

Lowercase -l answers “which files contain a match?” and stops reading a file after its first match. It does not count occurrences. If you need counts, use the separate grep counting guide.

List files without a match with grep -L

bash
grep -rL -e 'Copyright' ./src

Capital -L lists files with no matching line. It is different from -v, which selects nonmatching lines. Combining -v and -l lists files that have at least one nonmatching line, so it is not the inverse of -l.

On PowerShell, the equivalent file-level check is:

powershell
Get-ChildItem -LiteralPath './src' -Recurse -File |
  Where-Object { -not (Select-String -LiteralPath $_.FullName -Pattern 'Copyright' -CaseSensitive -Quiet) } |
  Select-Object -ExpandProperty FullName

Pass matching filenames safely to another command

Newline-separated paths break when a filename itself contains a newline. For GNU and current Apple grep, the shared spelling for a NUL-separated list is --null:

bash
grep -rl --null -F -e 'old' ./src | xargs -0 wc -l

Do not substitute -Z on macOS. GNU grep uses -Z for NUL filename output; Apple's -Z enables decompression. The letters look portable while doing different jobs.

For an edit, preview the files first and keep a backup. This Bash loop changes the simple literal word old to new and creates a .bak file beside each changed file:

bash
# Run in Bash, on a clean checkout; inspect the filename list first.
grep -rl --exclude='*.bak' -F -e 'old' ./src

grep -rl --null --exclude='*.bak' -F -e 'old' ./src |
  while IFS= read -r -d '' file; do
    sed -i.bak 's/old/new/g' "$file"
  done

sed -i.bak works with GNU and Apple sed. The replacement expression is still sed syntax: this is a fixed example, not a safe way to interpolate arbitrary search and replacement strings. Review the diff and the backups before keeping the changes. The loop performs no edits if no filename is emitted.

macOS BSD grep vs GNU grep

BehaviorGNU grepCurrent Apple grep
Recursive search-r-r or -R
Follow all symlinks while recursing-R-rS or -RS
Default recursive symlink behavior-r follows command-line symlinks, skips those found during traversalSkips symlinks; -O follows command-line links
Include/exclude file and directory globsSupportedSupported
NUL after filenames--null or -Z--null
-ZNUL filename outputDecompress input

Following links may leave the intended tree or encounter loops. I keep it disabled unless those linked paths are part of the task. A system with BusyBox grep or another BSD version can have different options again; use its own manual rather than transferring the Apple column blindly.

Common recursive-search mistakes

  • An unquoted glob becomes shell arguments. Use --include='*.log'; a regex such as .* is not a filename filter. For a literal string containing regex characters, add -F.
  • A missing path does not universally mean stdin. GNU recursive grep defaults to the current directory, and the current Apple binary does too. Plain nonrecursive grep without files reads stdin. Include . or a named directory explicitly to remove the ambiguity.
  • grep does not read .gitignore. Exclude dependency/build trees explicitly, or use rg for a search that follows ignore rules by default.
  • A filtered result is not a complete audit. Binary skips, permissions, exclusions and symlink rules all narrow coverage. Keep errors visible when completeness matters.

When to use another tool

Use git grep when the scope is tracked files in a repository. Use rg for day-to-day source searches with ignore-file support. Use find when modification time, permissions or path-specific selection matter. For structured JSON or XML, a parser such as jq or xmllint is less brittle than a text match.

FAQ

Use grep -rn -e 'pattern' . to search below the current directory. Add -F for literal text or -i for case-insensitive matching.

Use grep -rl -e 'pattern' . for matching files, or -rL for files with no matching line. Use --null when a program will consume the filename list.

Current Apple grep supports --include, --exclude and --exclude-dir. Symlink and -Z behavior still differ from GNU grep, so check the specific flags rather than assuming all options match.

See also

Sources

Authoritative references this article was fact-checked against.

TagsgrepCLIRecursive SearchLinuxmacOSBSDShell Scripting

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Systems and Network Architect · Chief Technology Officer

Systems and network architect and Chief Technology Officer with more than two decades designing, building, and running production software, cloud and network architecture, Linux systems, and the bare metal underneath them, and lately working AI into the stack. A US Army veteran who served in Operation Iraqi Freedom. What I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts