Linux Basic — Looping Directory Listings

4 minute read

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:

  1. * expands to every name in cwd that doesn’t start with . (hidden files are excluded by default)
  2. Each match becomes a separate word — quoting is preserved, no IFS surprises
  3. 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 * unless shopt -s globstar is set
  • Performance is fine for ~thousands of files; for millions, find is 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

Updated: