StackPractices
beginner By Mathias Paulenko

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 find with -exec or xargs
  • Operations requiring cross-file state — use a proper scripting language (Python, Perl)
  • Tasks that need error recovery per file — set -e with 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?

flowchart diagram: Files to iterate

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:

  1. Use mapfile for reading file lists into arrays. It’s faster than a while read loop 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
  1. Use xargs for 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
  1. Set IFS correctly when parsing command output. The default IFS includes spaces, which breaks filenames. Always use IFS= with read:
#!/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 nullglob when using globs in loops. Otherwise *.txt with no matches iterates once with the literal string *.txt.
  • Use -- before filenames in commands. Without it, a file called -rf gets read as flags by mv or rm.
  • Check [ -e "$file" ] at loop start. It’s a one-line guard for when nullglob is off and the glob matched nothing.

Common Mistakes

  • for file in $(ls *.txt) — never do this. ls output isn’t parseable; spaces and newlines in filenames break the loop.
  • Unquoted variables: mv $file $dest splits My Document.txt into My and Document.txt, so mv gets the wrong paths.
  • Forgetting nullglob: an unmatched glob expands to itself, so the loop still runs once — on the literal *.txt.
  • Using cat to feed a single file to a program: cat "$file" | grep pattern is a useless use of cat. Use grep pattern "$file".
  • Not handling the no-match case: An empty directory makes a naive loop run once on the literal *.txt — the same failure nullglob prevents.
  1. Using for file in $(find ...) instead of while read. Word splitting mangles any filename containing a space. Feed find through -print0 into while 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)
  1. Not using set -euo pipefail in scripts. Without set -e, errors in the loop body are silently ignored. Without set -u, undefined variables expand to empty strings. Without pipefail, 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
  1. 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

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.