skip to content

A bash script runs `for f in *.log; do process "$f"; done` in a directory that contains no .log files, and `process` is called once with the literal string `*.log`. Why does bash behave that way, and what are the correct fixes?

level: middleimportance: must knowfreq 62%

answer

  1. no match means no replacement
  2. the pattern survives as a literal word
  3. one iteration, not zero
  4. nullglob removes, failglob errors
  5. guard with an existence test

basics

~20 s

By default bash leaves a pattern that matches nothing unchanged, so the word *.log survives as a literal and the loop runs once. Fix it with shopt -s nullglob, shopt -s failglob, or an existence guard inside the loop.

solid answer

~50 s

Pathname expansion only replaces a word if at least one filename matches. When nothing matches, bash's default is to leave the word exactly as written, so `for f in *.log` iterates a single time with `f` set to the literal string `*.log`, and the body happily processes a file that does not exist. There are three honest fixes. `shopt -s nullglob` makes an unmatched pattern expand to nothing at all, so the loop body never runs — that is usually what you want in a loop, but be careful, because it also turns `cp *.log dst/` into `cp dst/`. `shopt -s failglob` makes the whole command fail with a "no match" error instead, which suits a script that genuinely requires the files. Or leave the options alone and guard the body with `[[ -e $f ]] || continue`, which is the portable version that does not change global shell state.

code

bash · 15 lines
bash
cd "$(mktemp -d)"

# default: one iteration with the literal pattern
for f in *.log; do echo "default: [$f]"; done

# nullglob: zero iterations
shopt -s nullglob
for f in *.log; do echo "nullglob: [$f]"; done
shopt -u nullglob

# portable guard: zero effective iterations, no global state change
for f in *.log; do
  [ -e "$f" ] || continue
  echo "guarded: [$f]"
done

go deeper

for a junior

Remember the default: a pattern that matches nothing is left as literal text, so the loop still runs once. Be able to add the [ -e "$f" ] || continue guard.

for a middle

Explain all three remedies and their tradeoffs, and describe precisely what nullglob does to a command's argument list — a vanished word, not an empty argument.

for a senior

Show you would choose per situation: failglob where missing artefacts must abort a release, nullglob scoped to the loop elsewhere, and never a global flip in a long script somebody else wrote.

for a principal

Own the convention across the codebase: decide which glob options the team's script template sets, and insist that scripts fail loudly on a missing input set rather than passing a pattern string downstream to another interpreter.

## The rule Pathname expansion is a replacement, and bash only performs the replacement if the pattern matches something. From the bash manual: if no matching filenames are found and the `nullglob` option is not set, the word is left unchanged. There is no error, no warning, no empty expansion — the pattern text simply survives into the command's argument list as an ordinary word. That rule turns a `for` loop over a glob into a loop that always runs at least once: ```bash cd "$(mktemp -d)" # empty directory for f in *.log; do echo "processing $f" # prints: processing *.log rm -- "$f" # rm: cannot remove '*.log': No such file or directory done ``` The bug is nasty because it hides. In development the directory always has a file, the loop looks correct, and the failure only appears the first time production hands you an empty directory — often as a confusing downstream error from whatever `process` does with a nonexistent path, or worse, as a tool that interprets the asterisk itself and does something far too broad. ## Fix 1: nullglob ```bash shopt -s nullglob for f in *.log; do process "$f"; done # zero iterations when nothing matches ``` `nullglob` makes an unmatched pattern expand to **nothing** — the word disappears from the command line. For a `for` loop that is exactly right: no matches means no iterations. It has a real hazard, though, and interviewers like it. Because the word vanishes entirely, any command whose argument count matters can silently mutate: ```bash shopt -s nullglob cp *.log /backup/ # becomes: cp /backup/ -> "missing destination file operand" ls *.log # becomes: ls -> lists the WHOLE directory grep ERROR *.log # becomes: grep ERROR -> hangs reading stdin ``` That last one is the classic: a pipeline stage that appears to hang in CI. So `nullglob` is best enabled deliberately and locally, ideally around the loop rather than at the top of a large script whose other commands were written under the default. ## Fix 2: failglob ```bash shopt -s failglob cp *.log /backup/ # bash: no match: *.log -- cp never runs ``` `failglob` makes an unmatched pattern an **error**: bash prints a no-match message and does not execute the command at all. Use it where the absence of files is genuinely a failure — a release script that must find artefacts to upload. Note the interaction with strict mode: the failed expansion means the command never runs, so nothing else in the pipeline gets a chance to mask it. `nullglob` and `failglob` are mutually exclusive in effect; if both are set, `failglob` wins for a non-matching pattern. ## Fix 3: guard the body ```bash for f in *.log; do [[ -e $f ]] || continue # or: [ -e "$f" ] || continue process "$f" done ``` This changes no global state, works in POSIX sh with `[ -e "$f" ]`, and is obvious to a reader who has met the trap before. Use `-e` rather than `-f` if directories or symlinks should count. The one edge it does not cover is a file literally named `*.log`, which is a curiosity rather than a real concern. A fourth option people reach for — `if [ -n "$(ls *.log 2>/dev/null)" ]` — is worse than all three: it forks, it parses `ls` output, and it breaks on filenames containing whitespace. ## Where else the default bites The same rule applies to any unquoted pattern anywhere, not just loops: - `rm -f *.tmp` in a clean directory tries to remove a file called `*.tmp`; `-f` silently swallows it, which is why the bug persists unnoticed for years. - `arr=( *.log )` produces a one-element array holding the pattern, so `${#arr[@]}` is 1 rather than 0. Checking `(( ${#arr[@]} ))` to decide whether files exist requires `nullglob` to be meaningful. - Passing an unmatched pattern onward — to `ssh`, `find`, `docker run` — hands the remote side an asterisk that *it* may expand, which is a security-relevant surprise rather than a cosmetic one. ## Scoping the option change `shopt` is shell state, so setting it in a sourced library changes the caller's behaviour. Two containment patterns are worth knowing: set it in a subshell — `( shopt -s nullglob; files=( *.log ); ... )` — or save and restore explicitly using `shopt -p nullglob`, whose output is a command that restores the previous setting: ```bash prev=$(shopt -p nullglob) # e.g. "shopt -u nullglob" shopt -s nullglob files=( *.log ) eval "$prev" ``` The general lesson generalises past globbing: bash's defaults were chosen for an interactive typist, where leaving the pattern visible is a helpful hint that you mistyped it. In a script, silence about a mismatch is the wrong default, and part of hardening a script is choosing the option that makes the mismatch loud.

  • What breaks if you set `shopt -s nullglob` at the top of an existing script?
    Every command whose argument count matters can change shape when a pattern matches nothing. `cp *.log dst/` becomes `cp dst/` and errors, `ls *.log` becomes a bare `ls` that lists everything, and `grep ERROR *.log` becomes `grep ERROR` reading standard input, which looks like a hang in CI. Enable it locally around the loop or array assignment that needs it.
  • Does `set -e` catch the unmatched-glob case?
    No. With the default option the expansion succeeds — bash considers a literal word a perfectly valid expansion — so nothing signals an error until the command itself fails, and `rm -f` or a tolerant tool may not fail at all. `failglob` is what turns a mismatch into a failure; strict mode then propagates it.
  • How do you collect matching files into an array and correctly detect the empty case?
    Enable nullglob for the assignment: `shopt -s nullglob; files=( *.log ); shopt -u nullglob`, then test `(( ${#files[@]} == 0 ))`. Without nullglob the array holds one element containing the literal pattern, so the count is 1 and the emptiness test silently reports files that do not exist.

saying these in an interview costs you the question

  • Assuming an unmatched glob expands to an empty string
  • Expecting the loop to run zero times by default
  • Thinking `set -e` catches a non-matching pattern
  • Enabling nullglob globally without auditing other commands
  • Testing for files by parsing `ls` output

context