I pointed a nightly upload loop at an empty folder of camera clips, the kind of folder you get on a night the phone recorded nothing, and it reported one clip uploaded. There was no clip. The loop had run once, with the pattern clips/*.mp4 itself as the file name.

That is documented Bash behavior, and the usual fix, shopt -s nullglob, brings its own surprise: with it on, ls clips/*.mp4 in an empty folder listed the files in the current directory instead. Below are the test scripts, what each one printed, what the Bash manual says about all three settings, what happens under /bin/sh, and the version of the job I would run instead.

Disclosure: I make an Android camera app. Nothing below uses its code or describes how it records, names or stores anything. The clips are random bytes or empty folders made on a Linux machine, and every output is pasted from the scripts shown. Versions:

GNU bash, version 5.1.16(1)-release (aarch64-unknown-linux-gnu) dash 0.5.11+git20210903+057cd650a4ed-3build1 (/bin/sh) ls (GNU coreutils) 8.32 tar (GNU tar) 1.34 find (GNU findutils) 4.8.0 

The loop on an empty night

The job counts the clips in a folder and copies each one to an upload directory. The folder exists, but nothing was recorded into it.

#!/bin/bash # A nightly job: count tonight's clips, then upload each one. # The phone recorded nothing, so the folder is empty. mkdir -p clips n=0 for f in clips/*.mp4; do n=$((n + 1)) echo "uploading: $f" cp "$f" /tmp/upload/ 2>&1 | sed 's/^/ /' done echo "clips uploaded tonight: $n" 
uploading: clips/*.mp4 cp: cannot stat 'clips/*.mp4': No such file or directory clips uploaded tonight: 1 exit status: 0 

The loop body ran once. $f held the literal text clips/*.mp4, cp failed on it, and the count went to one. The script still exited with status 0, because its last command was an echo that worked. A log that keeps only the last line says one clip went up on a night when none existed.

In this test the damage is a wrong number and a cp error in the middle of the output. In a job that does more with $f, such as writing it into a manifest, passing it to a remote command, or using it to build an output file name, the literal pattern goes wherever a real name would have gone.

What the manual says

The Bash Reference Manual, in the section on filename expansion, covers all three behaviors in one paragraph. The default: "If no matching filenames are found, and the shell option nullglob is disabled, the word is left unchanged."

The first alternative: "If the nullglob option is set, and no matches are found, the word is removed."

The second: "If the failglob shell option is set, and no matches are found, Bash prints an error message and does not execute the command."

The shopt page adds that "Unless otherwise noted, the shopt options are disabled (off) by default." Neither nullglob nor failglob is noted as on, so a script that does not set one gets the unchanged word. That is the loop above.

Turning on nullglob

nullglob fixes the loop: no match, no word, no iteration. The trouble is that it applies to every command in the script, and plenty of commands behave differently when handed no file arguments at all than when handed a pattern that matched nothing. Same empty folder, with two ordinary files in the directory the job runs from:

#!/bin/bash # Same empty folder, nullglob on. The loop is fixed; now watch what # the same setting does to commands that take the glob as arguments. shopt -s nullglob mkdir -p work/clips && cd work touch notes.txt; sleep 1; touch settings.json n=0 for f in clips/*.mp4; do n=$((n + 1)); done echo "loop count: $n" echo "== ls clips/*.mp4" ls clips/*.mp4; echo "exit $?" echo "== newest clip: ls -t clips/*.mp4 | head -n 1" ls -t clips/*.mp4 | head -n 1 echo "== rm -f clips/*.mp4" rm -f clips/*.mp4; echo "exit $?" echo "== rm clips/*.mp4" rm clips/*.mp4; echo "exit $?" echo "== tar czf night.tgz clips/*.mp4" tar czf night.tgz clips/*.mp4; echo "exit $?" 
loop count: 0 == ls clips/*.mp4 clips notes.txt settings.json exit 0 == newest clip: ls -t clips/*.mp4 | head -n 1 settings.json == rm -f clips/*.mp4 exit 0 == rm clips/*.mp4 rm: missing operand Try 'rm --help' for more information. exit 1 == tar czf night.tgz clips/*.mp4 tar: Cowardly refusing to create an empty archive Try 'tar --help' or 'tar --usage' for more information. exit 2 

Line by line:

  • The loop counted zero. That is the fix working.
  • ls clips/*.mp4 listed the current directory. With the word removed, the command became plain ls, and ls with no arguments lists where it is. It exited 0.
  • The newest-clip line picked settings.json. A job that uploads $(ls -t clips/*.mp4 | head -n 1) as the latest recording would have picked a settings file, without an error.
  • rm -f did nothing and exited 0. That is harmless here, and it is also why nobody notices the setting is on.
  • rm without -f failed with missing operand, and tar refused to make an empty archive and exited 2. Both are loud, which is better than ls, but a job written for the default behavior now fails on a night that used to pass.

So nullglob is not a setting to put at the top of an existing script and forget. Every command that receives a glob as arguments, rather than iterating over it, has to be checked for what it does with nothing.

Turning on failglob

failglob takes the opposite approach: an unmatched pattern is an error and the command does not run.

#!/bin/bash # failglob instead: an unmatched pattern is an error. shopt -s failglob mkdir -p clips echo "before the loop" for f in clips/*.mp4; do echo "uploading: $f"; done echo "after the loop, status $?" ls clips/*.mp4 echo "after ls, status $?" 
before the loop ./03_failglob.sh: line 6: no match: clips/*.mp4 after the loop, status 1 ./03_failglob.sh: line 8: no match: clips/*.mp4 after ls, status 1 exit status: 0 

Both the loop and ls were skipped, each with a no match error naming the line, and $? was 1 after each. The script itself kept going and exited 0. In this Bash, without set -e, failglob stopped the command and not the script.

With set -e added:

#!/bin/bash # failglob together with set -e. set -e shopt -s failglob mkdir -p clips echo "before the loop" for f in clips/*.mp4; do echo "uploading: $f"; done echo "after the loop" 
before the loop ./04_failglob_e.sh: line 7: no match: clips/*.mp4 exit status: 1 

Now the script stopped at the loop and exited 1. That is a clear failure, which is what you want if an empty folder means something is wrong, and not what you want if an empty folder is a normal night. Whether a night with no clips is an error depends on the setup: a motion-triggered camera may record nothing on a quiet night, and one that records continuously should not.

Under /bin/sh

A script run directly, by hand or from a crontab line, gets whatever interpreter its first line names, and plenty of scripts say #!/bin/sh. On this machine that is dash, which has no shopt:

#!/bin/sh # The same loop under /bin/sh, which is dash on this machine. mkdir -p clips shopt -s nullglob for f in clips/*.mp4; do echo "would upload: $f"; done for f in clips/*.mp4; do [ -e "$f" ] || continue echo "uploading: $f" done echo "guarded loop done" 
./05_dash.sh: 4: shopt: not found would upload: clips/*.mp4 guarded loop done exit status: 0 

dash could not find shopt, carried on, and the first loop ran once with the literal pattern, the same as Bash's default. A script that was tested under bash script.sh and then installed with a #!/bin/sh line loses its nullglob line silently, apart from one error message.

The second loop shows the portable guard: test $f with -e and continue if it does not exist. If the word was left unchanged, there is no such file and the body is skipped. The -e test is part of POSIX test, so the guard does not depend on Bash. One limit I did not test: a real file whose name is literally *.mp4 would pass the guard, since it exists.

The version I would run

For a Bash script, collect the matches into an array with nullglob on, turn it straight back off, and decide what an empty array means before doing anything with it.

#!/bin/bash # Collect matches into an array with nullglob on, then turn it back off # so no later command is changed by it. An empty night is reported. set -u dir=${1:-clips} mkdir -p /tmp/upload shopt -s nullglob clips=("$dir"/*.mp4) shopt -u nullglob if [ "${#clips[@]}" -eq 0 ]; then echo "no clips in $dir tonight; nothing uploaded" >&2 exit 3 fi for f in "${clips[@]}"; do cp -- "$f" /tmp/upload/ && echo "uploaded: $f" done echo "clips uploaded tonight: ${#clips[@]}" 

And a test of it on an empty folder, then on a folder with two clips, one with a space in its name:

#!/bin/bash # Run the job on an empty folder, then on a folder with two clips. rm -rf clips && mkdir clips ./06_upload.sh clips; echo "exit $?" head -c 1024 /dev/urandom > clips/2026-11-03_0100.mp4 head -c 1024 /dev/urandom > "clips/2026-11-03 0200.mp4" ./06_upload.sh clips; echo "exit $?" 
no clips in clips tonight; nothing uploaded exit 3 uploaded: clips/2026-11-03 0200.mp4 uploaded: clips/2026-11-03_0100.mp4 clips uploaded tonight: 2 exit 0 

What changed:

  • nullglob is on for one line. It changes how the array is filled and nothing else, so ls, rm and tar later in the script keep their usual behavior.
  • The count is the length of the array, so the number in the log and the files that were copied come from the same list.
  • An empty folder is reported and exits with its own status (3 here), so a monitor can tell an empty night apart from a failed upload. If an empty folder is a normal night for your setup, change that branch to exit 0, but keep the message.
  • Every expansion is quoted, and cp -- stops a name starting with a dash from being read as an option. The clip with a space in its name went through as one file.

For a /bin/sh script, the alternative is to let find do the matching. It treats a pattern with no matches as no output, not as a name:

#!/bin/sh # For /bin/sh: let find do the matching. No match prints nothing. mkdir -p clips n=$(find clips -maxdepth 1 -type f -name '*.mp4' | wc -l) echo "clips found: $n" find clips -maxdepth 1 -type f -name '*.mp4' -exec cp -- {} /tmp/upload/ \; echo "find exit: $?" 
clips found: 0 find exit: 0 

Zero clips found, nothing copied, and find exited 0. The count line runs find a second time, so on a folder that is still being written to, the two runs could disagree; in a real job I would write the list to a file once and count and copy from that.

What I would check in an existing job

  1. Search for unquoted patterns in for loops and command arguments, such as for f in dir/*.mp4 and ls dir/*.log, and ask what each does on an empty folder.
  2. Run the job once against an empty folder on purpose. That is the test that shows the literal pattern, and it takes a minute.
  3. If the script sets nullglob, look at every command that takes a glob as arguments, especially ls, which lists the current directory when given nothing.
  4. Check the first line. A #!/bin/sh script cannot use shopt; use the -e guard or find.
  5. Decide what an empty night means and make the job say so with an exit status, instead of letting a count of one or zero stand in for that decision.

What I did not test

  • Other shells. This was Bash 5.1.16 and dash on Linux. zsh, ksh and BusyBox sh were not part of it, and I have not checked how each handles an unmatched pattern.
  • Newer Bash. The online manual is for a later Bash than the one tested; it mentions settings such as GLOBSORT that 5.1 does not have. I only quote the parts about unmatched patterns.
  • dotglob, nocaseglob and globstar. All change what a pattern matches; none was turned on here.
  • Very large folders. A pattern that matches many thousands of files can run into the system's argument length limit, which is a different failure.

Sources. Quotes about unmatched patterns, nullglob, failglob and default settings are from the GNU Bash Reference Manual, Filename Expansion and The Shopt Builtin. Outputs are pasted unedited from the scripts shown.

If you want an old Android phone recording in the background so there is a folder of clips for a job like this to pick up, the app I make is Background Camera RemoteStream on Google Play. Nothing above depends on it.

By default, an empty night does not make the loop fail: it runs once on a file named clips/*.mp4 and counts it, so a night with nothing recorded reports the same total as a night with one clip.