Globbing, Brace Expansion, and Safe Bulk Operations
Bulk commands become dangerous when operators mistake a shell pattern for a literal argument. This lesson shows how Bash expands braces, variables, and globs before a command runs—and how to preview and preserve the exact generated argument list.
Learning objectives
By the end of this lesson
- Distinguish shell glob patterns from regular expressions.
- Use *, ?, and bracket expressions while understanding hidden-file behavior.
- Explain brace expansion and its position in the shell expansion sequence.
- Control unmatched patterns with nullglob and failglob.
- Build safe bulk operations with arrays, quoting, --, dry runs, and find -exec.
1. The shell transforms a command before the program sees it
When a command line contains braces, variables, command substitutions, spaces, or wildcard characters, Bash performs a sequence of expansions. The external program receives the resulting argument vector. It usually never sees the original quotes or wildcard expression.
flowchart TD C["Typed command line"] --> B["Brace expansion"] B --> V["Parameter and command expansion"] V --> W["Word splitting when applicable"] W --> G["Pathname expansion"] G --> Q["Quote removal"] Q --> A["Final argv passed to command"]
A destructive command should be reviewed as the exact list of
generated arguments. Use arrays and printf '%q\n' to
make spaces, leading dashes, and special characters visible.
2. Pathname expansion uses glob patterns, not regular expressions
*Zero or more characters*.log
?Exactly one characterapp-?.conf
[abc]One listed characternode-[123]
[a-z]One character in a locale-dependent rangerelease-[0-9]
[!0-9]One character not in the setfile-[!0-9]
lab="$HOME/devops-academy/linux/chapter04/lesson04"
rm -rf -- "$lab"
mkdir -p -- "$lab"
printf '%s\n' alpha > "$lab/app-1.log"
printf '%s\n' beta > "$lab/app-2.log"
printf '%s\n' gamma > "$lab/app-a.log"
printf '%s\n' delta > "$lab/app-10.log"
printf '%s\n' hidden > "$lab/.app-hidden.log"
cd -- "$lab"
printf 'single-character suffix matches:\n'
printf ' %s\n' app-?.log
printf '\nnumeric single-character matches:\n'
printf ' %s\n' app-[0-9].log
A normal * does not match a leading dot at the start of
a path component. This protects . and
.. from many patterns but also means a command such as
cp -a source/* destination/ omits hidden entries.
Copying source/. is often the clearer complete-tree
form.
3. Quoting decides whether metacharacters remain active
pattern='*.log'
printf 'Unquoted variable may split and glob:\n'
printf ' %s\n' $pattern
printf '\nQuoted variable is one literal argument:\n'
printf ' %s\n' "$pattern"
printf '\nQuoted wildcard is literal:\n'
printf ' %s\n' '*.log'
printf '\nEscaped wildcard is literal:\n'
printf ' %s\n' \*.log
Double quotes permit parameter and command substitution but suppress word splitting and pathname expansion of the resulting value. Single quotes preserve nearly every character literally. A wildcard typed outside quotes remains eligible for pathname expansion.
An array preserves argument boundaries. A string containing several filenames cannot safely represent arbitrary names because spaces, newlines, and wildcard characters become ambiguous when re-expanded.
4. Choose what an unmatched pattern means
By default, Bash leaves an unmatched pathname pattern unchanged. A
loop over *.log can therefore process the literal text
*.log when no files match. Shell options let scripts
choose safer behavior.
Pattern remains literal
Useful interactively for visibility, but scripts may mistake it for a real pathname.
nullglob
Unmatched pattern disappears
An array receives zero elements, which is convenient when the script checks its length.
failglob
Unmatched pattern is an error
Useful when absence indicates a failed precondition, though handling requires care because expansion fails before command execution.
dotglob
Wildcards include leading-dot names
Changes the target set broadly; enable only in a controlled scope.
empty="$lab/empty"
mkdir -p -- "$empty"
cd -- "$empty"
shopt -s nullglob
logs=( *.log )
printf 'nullglob match count=%d\n' "${#logs[@]}"
shopt -u nullglob
# Demonstrate failglob in a subshell so the parent shell continues.
if ( shopt -s failglob; printf '%s\n' *.log ) 2>"$lab/failglob.err"; then
printf 'unexpected match\n'
else
printf 'failglob rejected unmatched pattern\n'
fi
cat -- "$lab/failglob.err"
5. Brace expansion generates text before pathname matching
Brace expansion is textual. It does not inspect the filesystem. Bash
expands alternatives such as {dev,test,prod} and
sequences such as {01..05} before parameter expansion
and globbing.
root="$lab/environments"
mkdir -p -- "$root"/{dev,test,prod}/{config,logs}
printf 'Generated environment directories:\n'
printf ' %s\n' "$root"/{dev,test,prod}/{config,logs}
printf '\nSequence-generated candidate names:\n'
printf ' node-%02d.conf\n' {1..5}
# Variables are not expanded early enough to define brace syntax.
start=1
end=3
printf '\nThis remains literal brace text:\n'
printf ' %s\n' {$start..$end}
Brace expansion can generate nonexistent names, which is valuable for creation commands but risky if mistaken for a match query. Use globbing to select existing paths and brace expansion to construct predictable text combinations.
6. Use arrays for a reviewable bulk target set
cd -- "$lab"
shopt -s nullglob
candidates=( app-[0-9].log app-10.log )
shopt -u nullglob
printf 'Candidate count: %d\n' "${#candidates[@]}"
printf 'Quoted candidate argv:\n'
printf ' %q\n' "${candidates[@]}"
if ((${#candidates[@]} == 0)); then
printf 'No matching logs; nothing to do.\n'
else
mkdir -p -- archive
cp -vn -- "${candidates[@]}" archive/
fi
printf '\nArchive verification:\n'
find archive -maxdepth 1 -type f -printf '%f\n' | sort
"${array[@]}" expands to one quoted argument per
element. By contrast, ${array[*]} and unquoted
expansions can collapse or re-split boundaries. Always use the
[@] form for command arguments.
7. For recursive selection, prefer find with null-safe execution
Pathnames may contain spaces, tabs, newlines, wildcard characters,
and leading dashes. Parsing find output line by line is
therefore fragile unless a null delimiter is used. For direct
execution, -exec ... {} + passes names as arguments
without text parsing.
tree="$lab/tree"
mkdir -p -- "$tree/a" "$tree/b"
printf x > "$tree/a/old build.log"
printf y > "$tree/b/-leading-dash.log"
printf z > "$tree/b/keep.txt"
printf 'Preview selected logs:\n'
find "$tree" -type f -name '*.log' -printf ' %p\n'
mkdir -p -- "$lab/recursive-archive"
find "$tree" -type f -name '*.log' \
-exec cp -vn -- {} "$lab/recursive-archive/" +
printf '\nCopied basenames:\n'
find "$lab/recursive-archive" -maxdepth 1 -type f -printf '%f\n' | sort
Copying recursively selected files into one flat directory can overwrite or skip files that share a basename. Preserve relative paths or detect collisions before production use.
8. Hands-on lab: archive logs with preview and exact argv preservation
set -u
root="$HOME/devops-academy/linux/chapter04/lesson04-bulk"
source_dir="$root/incoming"
archive_dir="$root/archive"
rm -rf -- "$root"
mkdir -p -- "$source_dir" "$archive_dir"
printf 'ok\n' > "$source_dir/api 01.log"
printf 'warn\n' > "$source_dir/api 02.log"
printf 'ignore\n' > "$source_dir/api.tmp"
printf 'hidden\n' > "$source_dir/.internal.log"
printf 'dash\n' > "$source_dir/-special.log"
cd -- "$source_dir"
shopt -s nullglob
logs=( *.log )
shopt -u nullglob
printf 'Dry-run target list (%d files):\n' "${#logs[@]}"
printf ' %q\n' "${logs[@]}"
if ((${#logs[@]})); then
cp -vn -- "${logs[@]}" "$archive_dir/"
fi
printf '\nArchived files:\n'
find "$archive_dir" -maxdepth 1 -type f -printf '%f\n' | sort
printf '\nHidden source file remains excluded by normal glob:\n'
test -f "$source_dir/.internal.log" && printf 'yes\n'
Verification checklist
9. Common expansion mistakes
Calling *.log a regular expression
It is a shell glob. Regex syntax and matching semantics are different.
Looping over $(find ...)
Command substitution and word splitting corrupt pathnames containing whitespace or wildcard characters.
Forgetting unmatched-pattern behavior
The literal pattern may enter a loop or command unless
nullglob, failglob, or an explicit
existence check is used.
Using unquoted array expansion
Unquoted elements can be split and globbed again. Use
"${array[@]}".
10. Knowledge check
Question 1. What is the difference between brace expansion and pathname expansion?
Question 2. Why is
"${files[@]}" preferred when passing an array to a
command?
Question 3. What problem does
nullglob solve?
11. Summary
Bash constructs command arguments before external programs run.
Globs select existing pathnames, brace expansion generates text
combinations, and quoting controls whether metacharacters remain
active. Safe bulk operations materialize targets in arrays, preview
them with quoted output, handle zero matches explicitly, terminate
option parsing with --, and use null-safe recursive
tools.
12. Further reading
- GNU Bash Reference Manual — shell expansions, pattern matching, arrays, glob options, and quoting.
-
GNU Coreutils manuals — argument handling and
--conventions. -
GNU findutils manual — find expressions,
-exec, null delimiters, and filename safety. - POSIX Shell Command Language — pathname expansion, field splitting, and quoting.
- ShellCheck documentation — diagnostics for unsafe expansions and loops over command output.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.