How to Loop Over Files in Bash
How to safely loop over files and directories in Bash, handling spaces, globs, and large file lists with correct patterns.
Overview
Looping over files is one of the most common Bash operations, yet it’s frequently done wrong. Filenames with spaces, newlines, or glob characters (*, ?) break naive loops. Below are the patterns that survive those names — plain globs, find, mapfile, and xargs — and when each one is the right call.
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 (see Copy and Move Files Safely)
- Generating reports from a directory of input files
- Replacing text across a directory of files (see Bash Text Processing)
When NOT to Use
- Processing millions of files — argument list length limits (
ARG_MAX) will fail - Complex filtering better expressed 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
Two questions pick the right tool: do you need recursion or complex filters, and can the directory be empty?
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
set -euo pipefail
shopt -s nullglob
# Numeric sort on filenames like report_001.txt, report_002.txt.
# Expand the glob into an array and sort it — never `for f in $(ls ...)`.
files=(report_*.txt)
mapfile -t sorted < <(printf '%s\n' "${files[@]}" | sort -t_ -k2 -n)
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
Working with Large File Lists
For parallel fan-out beyond a single xargs -P, see Bash Parallel Execution. When the file list is large or unpredictable, three idioms beat a plain for loop:
- Use
mapfilefor reading file lists into arrays. It’s faster than awhile readloop for large lists and preserves special characters:
#!/bin/bash
# Read all .py files into an array safely
mapfile -d '' -t pyfiles < <(find src/ -type f -name '*.py' -print0)
echo "Found ${#pyfiles[@]} Python files"
for file in "${pyfiles[@]}"; do
echo " $file"
done
- Use
xargsfor simple one-command-per-file operations. It handles batching and parallelism automatically:
#!/bin/bash
# Compress all .log files in parallel (4 at a time)
find /var/log -type f -name '*.log' -print0 | xargs -0 -P 4 -I{} gzip "{}"
# Run eslint on all .js files, 20 at a time
find src/ -type f -name '*.js' -print0 | xargs -0 -n 20 eslint
- Set
IFScorrectly when parsing command output. The default IFS includes spaces, which breaks filenames. Always useIFS=withread:
#!/bin/bash
# Bad: IFS includes space, breaks on "My File.txt"
# echo "My File.txt" | while read file; do echo "$file"; done
# Output: "My" and "File.txt" on separate lines
# Good: IFS= prevents leading/trailing whitespace trimming
echo "My File.txt" | while IFS= read -r file; do echo "$file"; done
# Output: "My File.txt"
Companion files: every script on this page is runnable from the bash-loop-over-files companion repo, one .sh file per pattern.
What Works
- Always quote file variables.
"$file"prevents word splitting on spaces and interpretation of glob characters. - Use
find -print0 | while read -r -d ''for recursion or complex filters — it’s the only form that survives a filename containing a newline. - Enable
nullglobwhen using globs in loops. Otherwise*.txtwith no matches iterates once with the literal string*.txt. - Use
--before filenames in commands. Without it, a file called-rfgets read as flags bymvorrm. - Check
[ -e "$file" ]at loop start. It’s a one-line guard for whennullglobis off and the glob matched nothing.
Common Mistakes
for file in $(ls *.txt)— never do this.lsoutput isn’t parseable; spaces and newlines in filenames break the loop.- Unquoted variables:
mv $file $destsplitsMy Document.txtintoMyandDocument.txt, somvgets the wrong paths. - Forgetting
nullglob: an unmatched glob expands to itself, so the loop still runs once — on the literal*.txt. - 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 makes a naive loop run once on the literal
*.txt— the same failurenullglobprevents.
- Using
for file in $(find ...)instead ofwhile read. Word splitting mangles any filename containing a space. Feedfindthrough-print0intowhile IFS= read -r -d ''instead:
# Bad: breaks on spaces
# for file in $(find . -name "*.txt"); do echo "$file"; done
# Good: handles all filenames
while IFS= read -r -d '' file; do
echo "$file"
done < <(find . -name "*.txt" -print0)
- Not using
set -euo pipefailin scripts. Withoutset -e, errors in the loop body are silently ignored. Withoutset -u, undefined variables expand to empty strings. Withoutpipefail, piped commands that fail are masked:
#!/bin/bash
# Bad: errors are silently ignored
# for file in *.txt; do
# process "$file"
# done
# Good: abort on error, undefined variable, or pipe failure
set -euo pipefail
for file in *.txt; do
[ -e "$file" ] || continue
process "$file"
done
- Modifying files while iterating. Adding, removing, or renaming files during a glob loop can cause skipped or duplicate processing. Collect the file list first, then iterate:
#!/bin/bash
shopt -s nullglob
files=(*.tmp)
shopt -u nullglob
for file in "${files[@]}"; do
# Safe: we already captured the list
rm -- "$file"
done
More File-Looping Patterns
How do I loop over files sorted by modification time?
Use find with -printf and sort, or ls -t with mapfile:
#!/bin/bash
# Sort by modification time (newest first)
mapfile -d '' -t files < <(find . -type f -name '*.log' -printf '%T@ %p\0' | sort -rz -n | cut -z -d' ' -f2-)
for file in "${files[@]}"; do
echo "$(date -r "$file" '+%Y-%m-%d %H:%M') $file"
done
How do I skip files larger than a certain size?
Use find with -size filter:
#!/bin/bash
# Process only files smaller than 10MB
while IFS= read -r -d '' file; do
size=$(stat -c%s "$file")
echo "Processing $file ($((size / 1024)) KB)"
done < <(find . -type f -name '*.log' -size -10M -print0)
How do I loop over files matching multiple extensions?
Use brace expansion with nullglob, or find with repeated -name patterns:
#!/bin/bash
shopt -s nullglob
# Brace expansion
for file in *.{jpg,jpeg,png,webp}; do
[ -f "$file" ] || continue
echo "Image: $file"
done
# find with multiple -name
while IFS= read -r -d '' file; do
echo "Image: $file"
done < <(find . -type f \( -name '*.jpg' -o -name '*.jpeg' -o -name '*.png' -o -name '*.webp' \) -print0)
How do I handle filenames with newlines?
Only find -print0 with read -d '' handles filenames containing newlines. Glob patterns also work since Bash expands them correctly. Never use ls output or unquoted command substitution:
#!/bin/bash
# This handles filenames with newlines, spaces, and special characters
while IFS= read -r -d '' file; do
echo "Processing: $file"
done < <(find . -type f -print0)
Further Reading
- BashFAQ/020 — How can I loop over files? — the canonical reference on safe file iteration in Bash.
- BashPitfalls — why
for f in $(ls)and friends break on real filenames. - GNU Bash Manual — Looping Constructs —
for,while, anduntilsemantics.
Frequently Asked Questions
Why should I avoid for f in $(ls)?
It breaks on filenames with spaces or special characters. Use a glob pattern like for f in *.txt or while IFS= read -r with find -print0.
When should I use find instead of a glob loop?
Reach for find when you need recursion, size or date filters, or guaranteed-safe handling of arbitrary names via -print0. A plain for file in *.txt is enough for one flat directory.
How do I process files in subdirectories?
Run find . -type f -name "*.txt" -print0 and feed it into while IFS= read -r -d '' file. Globs like **/*.txt also recurse in Bash 4+ with globstar enabled, but find gives you filters for free.
Why does my loop run once with the literal *.txt when no files match?
With nullglob off, an unmatched glob expands to itself. Enable shopt -s nullglob or add [ -e "$file" ] || continue at the top of the loop.
How do I stop the loop on the first error?
A while loop fed by a pipeline runs in a subshell, so set -e won't abort reliably. Check the command result inside the body (cmd "$file" || break) or collect the list first with mapfile and use a for loop.
How do I loop only over files older than 30 days?
Add -mtime +30 to find — the + means strictly older than N days: find . -type f -name '*.log' -mtime +30 -print0 | while IFS= read -r -d '' file; do ... done.
Related Resources
Bash Parallel Execution
How to run shell commands in parallel with xargs, GNU parallel, and Bash background jobs while controlling concurrency and collecting results. Includes exit-code handling and a semaphore for rate-limited APIs.
RecipeText Processing in Bash: grep, sed & awk
How to build capable text processing pipelines with grep, sed, awk, cut, sort, uniq, and tr for log analysis and data transformation.
RecipeGenerate Temporary Files
How to create temporary files and directories safely with automatic cleanup across Python, Node.js, Java, and Bash.
RecipeBash Backup Rotation: Retention and Integrity
Rotate Bash backups into daily, weekly, and monthly archives with retention, encryption, integrity checks, and optional S3 storage.
RecipeLog Rotation and Compression in Bash
Rotate, compress and prune application logs with logrotate configs and custom bash scripts — including zstd, copytruncate, disk-space checks and S3 shipping.
RecipeMonitor Disk Usage
Alert when disk space crosses thresholds using a Bash script that checks mount points and notifies operators.