skip to content

What does the pattern `**` expand to in bash, what has to be enabled for it to be recursive, and how does `for f in **/*.js` differ from `find . -name '*.js'`?

level: middleimportance: nice to knowfreq 34%

answer

  1. two stars are one star by default
  2. shopt -s globstar, bash 4+
  3. zero or more directories, so shallow files count
  4. still no leading dots
  5. the whole list is built before the command runs

basics

~20 s

Without shopt -s globstar, ** behaves like a single * and stays inside one directory level. With globstar on (bash 4.0+), **/ matches zero or more directories, so **/*.js recurses. Unlike find, it still skips hidden entries and expands entirely in memory.

solid answer

~50 s

By default two adjacent asterisks are just an asterisk: `**/*.js` means the same as `*/*.js`, exactly one directory deep, which is why people are so often surprised that their "recursive" glob missed most of the tree. `shopt -s globstar`, added in bash 4.0, changes `**` so that when it is followed by a slash it matches zero or more directories and subdirectories, making `**/*.js` find .js files at every depth including the current one. The differences from `find` matter in practice. Globstar still obeys the leading-dot rule, so it skips hidden directories such as `.git` unless dotglob is on, whereas `find` descends into them. Since bash 4.3 globstar does not traverse symlinked directories, while `find -L` will. Bash expands the whole match list in memory before running the command, so a big tree can produce an "argument list too long" error that find's incremental `-exec` avoids. And globstar does not exist in bash 3.2, which is what macOS ships.

code

bash · 11 lines
bash
shopt -s globstar nullglob

# every .js at any depth, including the current directory
for f in **/*.js; do
  echo "linting $f"
done

# ** followed by a slash matches directories only
for d in **/; do
  echo "dir: $d"
done

go deeper

for a junior

Know that ** only recurses after shopt -s globstar and that plain ** is just *. Be able to write the enable line before the loop.

for a middle

Explain that **/ matches zero or more directories, so top-level files are included, and that the pattern still skips dot-directories and needs nullglob to handle the empty case.

for a senior

Bring the operational differences: full expansion in memory versus a streaming find, argument-list failures on large trees, symlink traversal changing in bash 4.3, and macOS bash 3.2 degrading silently rather than erroring.

for a principal

Decide the team-wide traversal convention and encode it: which interpreter version is guaranteed, whether tree walks go through git ls-files for ignore-awareness, and how scripts fail loudly instead of quietly covering a subset of the repository.

## The default is not recursion In a pattern, ordinary adjacent asterisks collapse: `**` matches the same set as `*`, because `*` already matches any run of characters within one path component and repeating it adds nothing. So the very common assumption that `**/*.js` is "all JavaScript files anywhere" is wrong in a default bash: ```bash shopt -u globstar echo **/*.js # identical to */*.js -- exactly one directory deep ``` This is a quiet failure. In a shallow project the pattern appears to work; on a deeper tree it silently misses most of the files, and a linting or formatting step covers a fraction of the codebase without anyone noticing. ## Turning on globstar ```bash shopt -s globstar for f in **/*.js; do echo "$f"; done ``` With `globstar` set, `**` used as a path component matches all files and zero or more directories and subdirectories. When it is followed by a `/`, it matches only directories and subdirectories. The "zero or more" part is important and often missed: `**/*.js` includes `app.js` in the current directory, not only files nested at least one level down. And `**/` on its own expands to every directory in the tree, which is handy for `chmod` sweeps. globstar arrived in **bash 4.0**. It does not exist in bash 3.2, the version Apple still ships as `/bin/bash` on macOS, so a script relying on it needs either a newer bash on the PATH or a different approach. ## Four differences from find **Hidden entries.** globstar is still pathname expansion and still refuses to match a leading dot, so `**/*.js` never descends into `.git`, `.cache` or `.github`. Depending on your goal this is a feature — you rarely want to lint vendored files inside a dot-directory — or a bug you must fix with `shopt -s dotglob`. `find` has no such rule and walks everything unless you prune explicitly. **Symbolic links.** Since **bash 4.3**, globstar no longer traverses symbolic links to directories. Earlier versions did, and a symlink pointing at a parent directory could send the expansion into an effectively unbounded walk. `find` does not follow symlinks by default either, but `find -L` does, so the two tools can be made to differ in both directions — be explicit about which behaviour you want. **Everything up front.** Bash builds the complete list of matches, sorts it, and substitutes it into the command line before the command runs. On a very large tree that means noticeable memory and latency, and if the expansion is passed to an external command it can exceed the system's argument-list limit and fail with "argument list too long". `find` streams, and `find ... -exec cmd {} +` batches arguments to stay under the limit. A `for` loop over a glob does not hit that limit — the loop is internal to bash — but `rm -- **/*.tmp` can. **Selection power.** find can filter on type, size, mtime, permissions and depth, prune subtrees, and emit NUL-separated names for safe consumption. A glob can only match names. When the selection criteria go beyond the name, that is the signal to switch tools. ## Choosing between them Globs win on safety and readability for name-based selection: each expanded word is a real pathname handed to the command as one argument, so filenames containing spaces or newlines are handled correctly with no extra ceremony, which is exactly where naive `find | while read` pipelines go wrong. ```bash shopt -s globstar nullglob for f in **/*.js; do # safe with any filename, zero iterations if none prettier --write "$f" done ``` Note `nullglob` alongside globstar: without it, an empty match still leaves the literal `**/*.js` in the loop. Pairing the two is the idiomatic combination for a script that walks a tree. find wins when you need non-name criteria, when the tree is large enough that materialising every path is wasteful, or when the script must run under `/bin/sh` where globstar does not exist at all. ## Repository-aware alternatives In a source tree, neither tool knows about ignore files. If the intent is "every tracked JavaScript file", `git ls-files '*.js'` is usually more correct than either, because it automatically excludes build output and vendored directories that the glob would happily include.

  • Why does a globstar sweep miss files under `.github` or `.config`?
    globstar is ordinary pathname expansion, so every component still refuses to match a name starting with a dot. Hidden directories are therefore never entered. Add `shopt -s dotglob` if you need them, and be aware that this also pulls in `.git`, which is almost never what a lint or format sweep wants.
  • When would `rm -- **/*.tmp` fail where a find-based equivalent succeeds?
    Bash expands the whole match set onto rm's command line, so a tree with a very large number of matches can exceed the system argument-list limit and fail with "argument list too long". `find . -name '*.tmp' -delete`, or `-exec rm -- {} +`, streams and batches instead, staying under the limit.
  • Your script uses `**` and has to run on a macOS developer laptop. What do you do?
    macOS ships bash 3.2, which predates globstar entirely, so the pattern silently degrades to a single-level match rather than failing loudly. Either require a newer bash via `#!/usr/bin/env bash` plus an explicit version check, or drop to `find` for the traversal, which works everywhere.

saying these in an interview costs you the question

  • Assuming `**` is recursive without enabling globstar
  • Thinking `**/*.js` skips files in the current directory
  • Expecting globstar to find files inside .git
  • Believing a glob sweep can never hit an argument-list limit
  • Using `**` in a script that must run on macOS bash 3.2

context