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?
answer
- no match means no replacement
- the pattern survives as a literal word
- one iteration, not zero
- nullglob removes, failglob errors
- guard with an existence test
basics
~20 sBy 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 sPathname 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 linescd "$(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]"
donego deeper
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.
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.
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.
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