TechEarl

How to grep Recursively Through a Directory

grep -r 'pattern' . searches every file under a directory tree. The catch is the path argument people forget, the -r vs -R symlink difference, and the unfiltered crawl into node_modules and .git. The flag reference, the --include and --exclude-dir filters, the macOS BSD vs GNU gaps, and when to reach for ripgrep or git grep instead.

Ishan KarunaratneIshan Karunaratne⏱️ 12 min readUpdated
Use grep -r 'pattern' . to search every file in a directory tree. The -r vs -R symlink difference, --include and --exclude-dir filters, -rl and -rn, and the macOS BSD vs GNU grep gaps.

grep -r 'pattern' . searches every file under the directory you point it at. The -r flag turns the file argument into a starting directory and grep walks the whole tree from there, printing path:line for every match. The . at the end is the search root, and it is the single most-forgotten part of the command.

The thing to know up front: -r does not follow symlinked directories, -R does. Pick -r unless you specifically need symlink traversal, because -R can walk into places you did not expect (and, in the worst case, loop). Everything else on this page is filtering: scoping the crawl to certain file types, skipping node_modules and .git, and trimming the output down to filenames or line numbers.

Set your values

Try it with your own values

Set your OS, search path, and pattern. Every grep example below updates with your values.

The one-liner

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

That walks every file under :search_path and prints each matching line prefixed with its filename. The . (or whatever path you set) is mandatory. grep -r ':pattern' with no path makes grep read from standard input and hang as if it is waiting for you to type, which is the classic "why is my terminal frozen" moment.

Both flags make grep recursive. The difference is what they do with symbolic links to directories.

FlagRecursiveFollows symlinked directories
-rYesNo
-RYesYes
--include / --exclude-dirn/aWorks with both (GNU)

-r traverses real directories only. If your tree contains a symlink pointing at another directory, -r lists the link but does not descend into it. -R follows the link and searches the target as if it were part of the tree.

Default to -r. Use -R only when you know the symlinks are intentional and you want their contents included. The risk with -R is symlink loops: a link that points back up the tree makes grep recurse forever (GNU grep detects most loops and warns, but BSD grep is less forgiving).

Recursive search filtered to certain files

A bare grep -r searches everything: source files, lock files, minified bundles, binary assets. Most of the time you want one file type. GNU grep has --include for exactly this.

bash· Linux (GNU)
grep -r --include='*.log' ':pattern' :search_path

--include='*.log' restricts the crawl to files matching the glob. You can pass it more than once (--include='*.js' --include='*.ts') to cover several extensions. BSD grep on macOS has no --include, so the portable fallback is find ... -print0 | xargs -0 grep, which the find files containing text article covers in depth.

Recursive search listing only filenames

When you only care which files contain the pattern, not the matching lines, add -l.

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

-rl prints one filename per matching file and stops reading that file after the first hit, so it is faster than a full search. It is the right form when you want to pipe the file list into another command. The grep list filenames article goes deeper on -l, -L (files without a match), and feeding the output into xargs.

Recursive search with line numbers

For code search you almost always want line numbers so you can jump straight to the hit.

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

-rn prints path:linenumber:matchedline. Most editors and terminals make that path:line prefix clickable, which turns grep into a navigation tool. Add -I (capital i) to skip binary files, which both GNU and BSD grep support, and the output stays clean even when the tree contains images or compiled artifacts.

grep -r vs find -exec grep

grep -r and find ... -exec grep both search a tree. They are good at different things.

Reach for grep -r when the filter is simple: a glob on the filename (--include) or a directory to skip (--exclude-dir). It is one command, it is fast, and the output formatting is built in.

Reach for find ... -exec grep (or find ... | xargs grep) when the file selection needs logic grep cannot express: files modified in the last day, files larger than a size, files with a specific permission bit, files matching a regex on the path rather than a simple glob. find does the selection, grep does the content match.

bash· Linux (GNU)
find :search_path -name '*.log' -mtime -1 -print0 | xargs -0 grep -n ':pattern'

That example ("recent .log files containing the pattern") is something grep -r alone cannot do, because grep has no concept of modification time. The -print0 / -0 pairing is important: it uses NUL as the separator so filenames with spaces survive the pipe. The find files containing text article is the full treatment of this pattern.

Excluding .git and node_modules

A recursive search inside a project directory will, by default, crawl .git and node_modules. That is thousands of files you never want in the results, and it makes the search slow and the output unreadable. GNU grep has --exclude-dir.

bash· Linux (GNU)
grep -rn --exclude-dir=node_modules --exclude-dir=.git ':pattern' :search_path

You can pass --exclude-dir as many times as you need. There is also --exclude='*.min.js' for skipping files by glob, and --exclude-from=FILE for reading a list of patterns from a file. BSD grep on macOS has none of these, so the macOS variant uses find with -prune to skip the directories before they are searched. The grep exclude files and directories article covers every exclude flag and the -prune equivalents.

If you do this on every search, the cleaner fix is ripgrep, which honours .gitignore automatically and never needs an --exclude-dir flag.

macOS BSD grep vs GNU grep

macOS ships BSD grep, not GNU grep, and the recursive-search story diverges in a few places that matter.

BehaviorGNU grep (Linux)BSD grep (macOS default)
-r recursiveSupportedSupported
-r follows symlinksNo (use -R)No (use -R), but older macOS followed by default
-R recursive + symlinksSupportedSupported
--include=GLOBSupportedNot supported
--exclude=GLOBSupportedNot supported
--exclude-dir=DIRSupportedNot supported
-I skip binary filesSupportedSupported
-l / -n recursiveSupportedSupported

The headline gap is the filtering flags. --include, --exclude, and --exclude-dir are GNU-only, and they are exactly the flags you reach for during recursive search. On macOS you either fall back to find ... | xargs grep, or install GNU grep with brew install grep and call it as ggrep (alias it to grep in your shell rc and the friction disappears). The symlink default also drifted across macOS releases, so if a script depends on it, be explicit: -r for "do not follow", -R for "follow".

Common grep -r mistakes

1. Forgetting the path argument. grep -r 'pattern' with no directory makes grep read from standard input. The terminal looks frozen; it is actually waiting for you to type or pipe data in. Always end the command with a path, even if it is just . for the current directory.

2. Letting -r crawl into huge directories. Running grep -r from a project root with no --exclude-dir searches node_modules, .git, dist, vendor, and every other heavy directory. The search is slow and the signal drowns in noise. Add --exclude-dir for the obvious offenders, or use git grep inside a repo.

3. Using -R when -r was meant. -R follows symlinked directories. If your tree has a symlink that points back up toward an ancestor, -R can recurse into a loop. GNU grep detects most loops and prints a warning; BSD grep handles it less gracefully. Use -r unless you have a concrete reason to follow links.

4. Unquoted patterns. grep -r *.log . lets the shell expand *.log into a file list before grep ever runs. Quote the pattern: grep -r '*.log' .. The same applies to any pattern containing *, ?, [, or $.

5. Expecting --include to work on macOS. It silently does nothing useful on BSD grep, or errors, depending on the version. If a recursive search "ignores" your --include, check whether you are on the system grep. Switch to ggrep or the find | xargs form.

6. Mismatched separators in the find pipeline. find ... | xargs grep breaks on filenames with spaces. Use find ... -print0 | xargs -0 grep so NUL separates the entries instead of whitespace.

When NOT to use grep -r

grep -r is the universal tool, available on every Unix box with no install. But it is not always the best one.

  • Use ripgrep (rg) for speed in a repo. It is dramatically faster on large trees, recursive by default, and respects .gitignore so node_modules and build output are skipped without any flags. For day-to-day code search on a workstation, rg 'pattern' beats grep -rn --exclude-dir=... 'pattern' . on both speed and ergonomics.
  • Use git grep inside a git repository. It searches only tracked files, so it never touches .git, ignored build artifacts, or untracked junk, and it is fast because it works off git's index. git grep -n 'pattern' is the right call when "the codebase" means "files git knows about".
  • Use find -exec grep when file selection needs logic. Modification time, size, permission bits, path regex: anything beyond a filename glob belongs to find. See find files containing text.
  • Use a structured-data tool for structured data. grep -r is line-oriented and content-blind. For JSON, jq; for XML, xmllint --xpath; for CSV, csvgrep. Greping structured formats works until a value spans lines or a delimiter appears inside a field.

For a one-off search on a production server where you cannot install anything, grep -r is exactly right. For repeated searches in a checkout you control, rg or git grep will serve you better.

See also

FAQ

TagsgrepCLIRecursive SearchLinuxmacOSBSDShell Scripting
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years across software, Linux systems, DevOps, and infrastructure — and a more recent focus on AI. Currently Chief Technology Officer at a tech startup in the healthcare space.

Keep reading

Related posts

How to grep case-insensitively with grep -i. Combine -i with -r, -w, -v, -c, the locale caveat for non-ASCII case folding, the PCRE (?i) inline flag, and BSD vs GNU grep differences.

How to grep Case-Insensitively (grep -i)

grep -i 'pattern' file matches regardless of case. The flag pairs with -r, -w, -v, and -c the way you would expect, but -i only folds ASCII case reliably. Non-ASCII case folding (accented characters, the Turkish dotted-i) depends on your locale. The combinations, the locale caveat, the PCRE per-pattern (?i) flag, and the BSD vs GNU differences.

Grow an AWS EBS volume with zero downtime: aws ec2 modify-volume to enlarge, wait for the optimizing state, then sudo growpart to extend the partition and sudo resize2fs (ext4) or sudo xfs_growfs (XFS) to stretch the filesystem. No detach, no reboot, on a live EC2 instance.

How to Extend an AWS EBS Volume Without a Restart

Grow an EBS volume on a running EC2 instance in four steps. Modify the volume, wait for the optimizing state, expand the partition with growpart, then stretch the filesystem with resize2fs or xfs_growfs. No detach, no reboot.

Wire ElasticPress to WP_Query so WordPress queries hit Elasticsearch or OpenSearch instead of MySQL. Install, indexable post types, ep_integrate, wp-cli index, faceted aggregations, and when ES actually beats MySQL FULLTEXT.

How to Use ElasticPress with WP_Query

Wire ElasticPress to WP_Query so WordPress queries hit Elasticsearch (or OpenSearch) instead of MySQL. Covers installation, indexable post types, ep_integrate, the wp-cli index command, faceted search with aggregations, and when ES actually beats MySQL FULLTEXT.