Bash Loop Over Files
How to safely loop over files and directories in Bash, handling spaces, globs, and large file lists with correct patterns.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Looping over files is one of the most common Bash operations, yet it is frequently done incorrectly. Filenames with spaces, newlines, or glob characters (*, ?) break naive loops. This recipe shows safe, portable patterns for iterating files, filtering by extension, recursing into subdirectories, and processing results.
When to Use
- Running the same command on many files (convert, analyze, move)
- Finding files matching a pattern and processing them in order
- Bulk renaming, permission changes, or validation
- Generating reports from a directory of input files
- Replacing text across multiple files
When NOT to Use
- Processing millions of files — argument list length limits (
ARG_MAX) will fail - Complex filtering that is easier in
findwith-execorxargs - Operations requiring cross-file state — use a proper scripting language (Python, Perl)
- Tasks that need error recovery per file —
set -ewith loops is tricky
Step-by-Step Implementation
Basic Safe Loop with Glob
#!/bin/bash
set -euo pipefail
# CORRECT: Always quote the variable
txt_count=0
for file in *.txt; do
# Handle the no-match case (glob leaves literal '*.txt')
[ -e "$file" ] || continue
echo "Processing: $file"
((txt_count++))
done
echo "Total .txt files: $txt_count"
Recurse with find
#!/bin/bash
set -euo pipefail
# Process all .py files under src/, safely handling spaces
while IFS= read -r -d '' file; do
echo "Linting: $file"
pylint "$file"
done < <(find src/ -type f -name '*.py' -print0)
# One-liner version with xargs (no loop needed)
find src/ -type f -name '*.py' -print0 | xargs -0 pylint
# Process with a limit (safer for huge directories)
find src/ -maxdepth 2 -type f -name '*.py' -print0 | \
xargs -0 -n 10 -P 4 pylint
Filter and Sort
#!/bin/bash
# Numeric sort on filenames like report_001.txt, report_002.txt
for file in $(ls -1 report_*.txt | sort -t_ -k2 -n); do
echo "Processing in order: $file"
done
# Safer alternative using array + glob
files=(report_*.txt)
IFS=$'\n' sorted=($(sort -t_ -k2 -n <<< "${files[*]}")); unset IFS
for file in "${sorted[@]}"; do
echo "Ordered: $file"
done
Process Files with Spaces and Special Characters
#!/bin/bash
set -euo pipefail
# Handle filenames with spaces, newlines, and globs
srcdir="/data/uploads"
# Approach 1: read with find -print0
while IFS= read -r -d '' filepath; do
filename=$(basename "$filepath")
echo "File: $filename"
done < <(find "$srcdir" -type f -print0)
# Approach 2: shopt nullglob + quoted expansion
shopt -s nullglob
targets=("$srcdir"/*)
shopt -u nullglob
for filepath in "${targets[@]}"; do
[ -f "$filepath" ] || continue
echo "Found: $(basename "$filepath")"
done
Bulk Operations
#!/bin/bash
set -euo pipefail
# Rename .jpeg to .jpg
for file in *.jpeg; do
[ -e "$file" ] || continue
mv -- "$file" "${file%.jpeg}.jpg"
done
# Convert all HEIC images to JPEG
for file in *.heic; do
[ -e "$file" ] || continue
base="${file%.heic}"
heif-convert "$file" "$base.jpg"
done
# Validate all JSON files
error_count=0
for file in *.json; do
[ -e "$file" ] || continue
if ! jq empty "$file" 2>/dev/null; then
echo "ERROR: Invalid JSON in $file" >&2
((error_count++))
fi
done
[ "$error_count" -eq 0 ] || exit 1
Best Practices
- Always quote file variables.
"$file"prevents word splitting on spaces and interpretation of glob characters. - Use
find -print0 | while read -r -d ''for recursive or complex filtering. It is the only portable way to handle all valid filenames. - Enable
nullglobwhen using globs in loops. Otherwise*.txtwith no matches iterates once with the literal string*.txt. - Use
--before filenames in commands.mv -- "$file" "$dest"prevents filenames starting with-from being interpreted as options. - Check
[ -e "$file" ]at loop start. Handles bothnullglobdisabled and empty directory cases.
Common Mistakes
for file in $(ls *.txt)— never do this.lsoutput is not parseable; spaces and newlines in filenames break the loop.- Unquoted variables:
mv $file $destfails onMy Document.txtbecause it splits into two arguments. - Forgetting
nullglob: The loop body runs once with*.txtas the filename when no matches exist. - Using
catto feed a single file to a program:cat "$file" | grep patternis a useless use ofcat. Usegrep pattern "$file". - Not handling the no-match case: An empty directory with a naive loop can produce unexpected behavior or errors.