grep -r --exclude-dir=node_modules 'pattern' . searches a whole tree but skips the node_modules directory. That single flag is the most-asked grep question there is, because grep does not read .gitignore. Unlike ripgrep, which auto-skips ignored paths, grep searches everything under the path you give it: build output, vendored dependencies, .git internals, minified bundles. Excluding them is manual, and the flags that do it are --exclude, --exclude-dir, and --include.
This page covers all three, the one-liner I actually paste, how to stack multiple excludes, reading the glob list from a file, and the find -prune fallback for the older BSD grep that shipped on macOS before version 12.
Set your values
Set your OS, search path, and the pattern. Every grep example below updates with your values.
The one-liner
The command that covers the common case: search recursively, skip the three directories nobody wants in their results.
grep -rn --exclude-dir={.git,node_modules,dist} ':pattern' :search_pathThe {.git,node_modules,dist} part is brace expansion. The shell expands it into three separate --exclude-dir arguments before grep ever runs, so the command grep receives is really --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=dist. That distinction matters and I come back to it under common mistakes.
-r makes the search recursive, -n prefixes each match with its line number. Add -I (capital i) to also skip binary files.
Exclude files by name with --exclude
--exclude=GLOB skips any file whose name matches the glob. The classic use is dropping minified assets and lock files from a code search.
grep -rn --exclude='*.min.js' ':pattern' :search_pathThe glob matches against the file name only, not the full path. *.min.js catches app.min.js and vendor.min.js anywhere in the tree. Quote the glob so the shell does not expand it against the current directory first.
To stack multiple file excludes, repeat the flag or use brace expansion:
grep -rn --exclude={'*.min.js','*.map','*.lock'} ':pattern' :search_pathBoth forms produce the same result. Brace expansion is shorter; repeated --exclude= flags are clearer in a script someone else will read.
Exclude whole directories with --exclude-dir
--exclude-dir=NAME is the one most people are actually looking for. It prunes an entire directory subtree, so grep never descends into it at all. That is faster than --exclude on files, because grep skips the directory wholesale instead of testing every file inside it.
grep -rn --exclude-dir=node_modules ':pattern' :search_pathMultiple directory excludes work the same way as file excludes: repeat the flag, or brace-expand.
grep -rn --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=dist ':pattern' :search_pathOne important rule: --exclude-dir takes a directory name, not a path. --exclude-dir=node_modules skips every node_modules directory anywhere in the tree, which is usually what you want in a monorepo. --exclude-dir=src/node_modules does not work the way you expect because it is matched as a glob against the directory name, and a name never contains a slash. If you need to exclude one specific path and not others, the find -prune approach below gives you that control.
Search only matching files with --include
--include=GLOB is the inverse of --exclude. Instead of listing what to skip, you list what to keep, and grep searches only files whose name matches. This is the cleaner choice when you want one file type and nothing else.
grep -rn --include='*.py' ':pattern' :search_pathThat searches only Python files. No node_modules problem to solve, because .js files under node_modules never match *.py in the first place. When your search is type-specific, --include is simpler than chaining excludes.
--include also stacks. To search a couple of file types:
grep -rn --include='*.ts' --include='*.tsx' ':pattern' :search_pathYou can combine --include and --exclude-dir in the same command. Search only TypeScript, but still skip node_modules so vendored .d.ts files do not pollute the results.
Read the glob list from a file
When the same set of excludes shows up across many commands, hard-coding it gets repetitive. --exclude-from=FILE reads the exclude globs from a file, one per line. There is a matching --include-from=FILE for the inverse.
First, the glob file. One pattern per line, no quotes (the shell is not involved):
*.min.js
*.map
*.lock
package-lock.jsonThen point grep at it:
grep -rn --exclude-from=.grepignore ':pattern' :search_pathOne caveat worth knowing: --exclude-from lists file globs only. There is no --exclude-dir-from. If you want a single file to hold directory excludes too, you either keep them in the command line or wrap the whole thing in a shell function. This is the closest grep gets to a .gitignore, and it is not very close.
grep does not read .gitignore
This is the root of the confusion. grep has no concept of an ignore file. It searches exactly what you point it at, every file under the path, including everything git has been told to ignore. The .gitignore-aware behavior people expect belongs to ripgrep (rg), which skips ignored paths by default and is the reason rg "just works" inside a repo without any exclude flags.
If you want grep-the-command to respect .gitignore, there are two practical routes:
- Use
git grepinstead. It only searches files thatgittracks, so ignored and untracked files are excluded automatically. The tradeoff: it misses files you have not staged yet. - Switch to
ripgrep.rg 'pattern'skips.gitignoreentries,.ignoreentries, and hidden files by default. For local development inside a repo this is almost always the better tool.
For everything else (production servers where you cannot install extras, searching paths that are not git repos at all), the manual --exclude-dir flags are what you have.
macOS: BSD grep vs GNU grep
--include, --exclude, and --exclude-dir are GNU extensions. They are not in the POSIX spec. The older BSD grep that macOS shipped for years did not have them at all, and a script using --exclude-dir would fail outright on a 2019 Mac.
| Flag | GNU grep | BSD grep (older macOS) | BSD grep (macOS 12+) |
|---|---|---|---|
--include=GLOB | Supported | Not supported | Supported |
--exclude=GLOB | Supported | Not supported | Supported |
--exclude-dir=NAME | Supported | Not supported | Supported |
--exclude-from=FILE | Supported | Not supported | Supported |
--include-from=FILE | Supported | Not supported | Supported |
macOS 12 (Monterey) and later added these flags to the system grep, so on a current Mac the GNU-style commands on this page work as written. If you have to support older macOS, or you just want certainty, you have two choices: install GNU grep with brew install grep and call it as ggrep, or fall back to find with -prune.
The fallback prunes the directory in find, then pipes the surviving files into xargs grep:
find :search_path -type d -name node_modules -prune -o -type f -print0 | xargs -0 grep -n ':pattern'-print0 and xargs -0 use NUL separators so filenames with spaces survive the pipe. The find -prune article covers the -prune operator in full, including how to prune several directories at once.
Common mistakes
1. Passing a path to --exclude-dir instead of a name. --exclude-dir=app/node_modules does not exclude that one path. The value is matched as a glob against directory names, and names never contain slashes, so it matches nothing. Use --exclude-dir=node_modules to skip the directory everywhere, or find -prune when you genuinely need one specific path excluded.
2. Thinking brace expansion is a grep feature. {.git,node_modules,dist} is expanded by the shell, not by grep. grep only ever sees the expanded result. This means brace expansion works in Bash and Zsh but not in sh/dash, and not at all if the braces are quoted. If --exclude-dir={.git,dist} mysteriously fails, check what shell the script runs under, and check the braces are unquoted.
3. Forgetting grep ignores .gitignore. A search returns ten thousand hits from node_modules and the instinct is "grep is broken". grep is doing exactly what it was told. It has no ignore file. Either add --exclude-dir, switch to git grep, or use ripgrep.
4. Quoting the glob wrong. --exclude=*.min.js unquoted lets the shell expand *.min.js against the current directory before grep runs. If a matching file exists in your cwd, grep receives that filename as the exclude value and silently excludes the wrong thing. Always quote: --exclude='*.min.js'.
5. Expecting --exclude to match a path. Like --exclude-dir, --exclude matches against the file name only. --exclude='build/*.js' does not work, because the file name has no build/ in it. Exclude the directory with --exclude-dir=build instead.
When NOT to use these flags
The manual exclude flags are the right tool when you are searching arbitrary paths outside a git repo, or on a server where you cannot install anything. Inside a repo, there is usually a better option:
- Use
ripgrepfor local code search.rgrespects.gitignoreautomatically, skips hidden files and binaries by default, and is dramatically faster on large trees. You almost never need an exclude flag withrg. See grep vs ripgrep vs ag for the full comparison. - Use
git grepwhen you only care about tracked files. It searches the git index, so ignored and untracked files are excluded for free, with no flags. The limitation: brand-new files you have not added yet are invisible to it. - Reach for
find -prunewhen you need path-specific exclusion. grep's--exclude-dirmatches names globally. If you must excludefrontend/distbut keepbackend/dist,findwith-pruneis the tool that can express that.
See also
- grep cheat sheet: the full grep reference covering regex flavors, context lines, and BSD vs GNU differences
- grep recursive search: the
-r/-Rflag in depth, including the symlink-following difference between GNU and BSD - grep vs ripgrep vs ag: when the
.gitignore-aware tools are worth switching to - Exclude a directory with find -prune: the fallback for older macOS grep, and the way to exclude a specific path
- External: GNU grep manual, FreeBSD grep(1) man page





