Linux Basic — Looping Directory Listings
Looping Directory Listings
The right tool for “loop over files in a directory” is a glob, never ls. Globs are built into the shell, handle spaces and special characters correctly, and need no forks.
The Basic Form
for f in *; do
[[ -e "$f" ]] || continue # see "empty glob" below
echo "[$f]"
done
Three rules:
*expands to every name in cwd that doesn’t start with.(hidden files are excluded by default)- Each match becomes a separate word — quoting is preserved, no IFS surprises
- If no files match, the glob is left as the literal
*— you must guard against it
The Empty-Glob Trap
By default, an unmatched glob is passed through literally:
cd /tmp/empty-dir # contains no files
for f in *.txt; do
echo "[$f]"
done
# [*.txt] ← the literal pattern! confusing.
Two ways to fix this:
Solution A — Guard with -e
for f in *.txt; do
[[ -e "$f" ]] || continue
echo "[$f]"
done
Simple and portable.
Solution B — nullglob
shopt -s nullglob
for f in *.txt; do
echo "[$f]"
done
# (nothing — the loop body just doesn't run)
shopt -u nullglob
nullglob makes unmatched globs expand to nothing (zero words). Cleaner — but it’s a shell-wide option, so set/unset it tightly around use, or live with it for the whole script.
failglob for Strictness
shopt -s failglob
ls *.nope
# bash: no match: *.nope (error, command not run)
Useful when you want the script to fail loudly if you typo a glob.
Including Hidden Files — dotglob
# Default
echo *
# a.txt b.txt main.sh (no dotfiles)
shopt -s dotglob
echo *
# .git .hidden a.txt b.txt main.sh
shopt -u dotglob
Or explicitly include them:
for f in * .[!.]* ..?*; do
[[ -e "$f" ]] || continue
echo "[$f]"
done
That triple-pattern is the portable “match hidden too” idiom: * (visible), .[!.]* (hidden but not . and not ..), ..?* (hidden starting with .. but not literally ..).
Recursing With globstar
shopt -s globstar
for f in **/*; do
[[ -f "$f" ]] || continue
echo "[$f]"
done
** matches any number of directory levels (Bash 4+). This is the simplest recursive iteration — no find, no NUL bytes.
Caveats:
**is just*unlessshopt -s globstaris set- Performance is fine for ~thousands of files; for millions,
findis faster
Combine with nullglob for safety:
shopt -s globstar nullglob
logs=( **/*.log )
echo "Found ${#logs[@]} log files"
Capturing Glob Results Into an Array
shopt -s nullglob
files=( *.csv )
echo "Found ${#files[@]} CSV files"
for f in "${files[@]}"; do
process "$f"
done
Storing the glob result first lets you iterate twice, count, sort, etc. without re-globbing.
Iterating Only Files / Only Directories
for f in *; do
[[ -f "$f" ]] && echo "file: $f"
[[ -d "$f" ]] && echo "dir: $f"
done
-f / -d test the type. Combine with ! -L to skip symlinks:
for f in *; do
[[ -f "$f" && ! -L "$f" ]] && echo "regular file: $f"
done
For symlink targets, [[ -f "$f" ]] follows the link; use [[ -L "$f" ]] to test “is a symlink.”
Case-Insensitive Globs
shopt -s nocaseglob
ls *.JPG # also matches .jpg, .Jpg, etc.
shopt -u nocaseglob
A Real Example — Compress Old Log Files
shopt -s nullglob
for f in /var/log/myapp/*.log; do
# Skip files modified in the last day
if [[ -z "$(find "$f" -mtime -1 -print -quit)" ]]; then
gzip "$f"
echo "Compressed: $f"
fi
done
Pure glob iteration, find just for the age test (one fork per file — acceptable for log management).
For high-volume cases, do it all in find:
find /var/log/myapp -name '*.log' -mtime +1 -exec gzip {} +
find is covered in find & xargs.
Sorted Iteration
Globs return matches in alphabetical order by default (the same as LC_COLLATE). To sort differently:
# By mtime (newest first)
for f in $(ls -t *.log 2>/dev/null); do … # ❌ parses ls
# ✅ Right — find + sort
mapfile -t files < <(find . -maxdepth 1 -name '*.log' -printf '%T@ %p\n' \
| sort -rn | cut -d' ' -f2-)
(-printf is GNU-only; on BSD use stat -f '%m %N' *.log | sort -rn.)
Common Mistakes
| ❌ Wrong | ✅ Right | Issue |
|---|---|---|
for f in $(ls) |
for f in * |
parses ls; breaks on spaces |
for f in *.log; do … in possibly-empty dir |
[[ -e "$f" ]] \|\| continue or shopt -s nullglob |
literal *.log runs through loop |
Forgetting globstar |
shopt -s globstar; for f in **/* |
** is just * otherwise |
for f in * and expecting hidden files |
use dotglob or the triple-pattern |
dotfiles excluded by default |
Quoting the glob: for f in "*.log" |
leave it unquoted | quoting makes it literal |
Next → Looping Command Output