Bash has two distinct for loop forms and three common iteration patterns layered on top of them. The list form (for x in a b c; do ...; done) iterates over a space-separated word list — the workhorse for arrays, files matched by a glob, brace expansions, and command substitutions. The C-style form (for ((i=1; i<=10; i++)); do ...; done) iterates over an integer with explicit init, test, and increment expressions — useful when you need arithmetic control. Below is the full reference: every syntactic variant with working code, the safe pattern for filenames with spaces, glob no-match behavior and how to fix it, parallel execution, and the macOS Bash 3.2 vs Linux Bash 4+ differences that catch cross-platform scripts.
How do I write a for loop in Bash?
A Bash for loop iterates over a sequence of items and runs a block of commands for each one. The simplest form uses a brace range to count from N to M: for i in {1..10}; do echo "$i"; done prints 1 through 10. To iterate over array elements, use for item in "${arr[@]}"; do ...; done with the quotes and [@] syntax. To iterate over files matching a pattern, use for file in *.txt; do ...; done (Bash expands the glob before the loop starts, so the loop sees zero or more matching filenames). For arithmetic control with explicit init/test/increment, use the C-style form: for ((i=1; i<=N; i++)); do ...; done. Every for loop is closed with done. For the conditional-test-driven sibling, see Bash While Loops.
Jump to:
- Quick reference: the four most common forms
- List-form syntax
- Brace ranges and sequence expressions
- Iterating over arrays
- Iterating over files (globs)
- C-style for loop
- Nested for loops
- Safe file iteration with spaces and newlines
- break and continue
- Parallel execution
- Common pitfalls
- macOS Bash 3.2 vs Linux Bash 4+
- Related: while, until, select
- FAQ
Quick reference: the four most common forms
# 1. Brace range — count 1 to 10
for i in {1..10}; do echo "$i"; done
# 2. Array
fruits=("apple" "banana" "cherry")
for fruit in "${fruits[@]}"; do echo "$fruit"; done
# 3. Glob — every .txt file in the current directory
for file in *.txt; do echo "Processing $file"; done
# 4. C-style — arithmetic control
for ((i=1; i<=10; i++)); do echo "Step $i"; doneThese four cover ~90% of real-world for-loop use. The rest of this page covers the syntax in depth and the safety patterns that turn working scripts into scripts that don't break on filenames with spaces.
List-form syntax
for VARIABLE in ITEM_1 ITEM_2 ITEM_3; do
# commands using $VARIABLE
done- VARIABLE — a name (no
$) that takes each item in turn. - ITEM_1 ITEM_2 ITEM_3 — a whitespace-separated word list. Bash performs word splitting and globbing on this list BEFORE the loop starts.
- The body between
doanddoneruns once per item.
A literal list:
for color in red blue green; do
echo "Color: $color"
doneA list from command substitution:
for user in $(cut -d: -f1 /etc/passwd); do
echo "User: $user"
doneA list from a brace expansion:
for env in {dev,staging,prod}; do
echo "Env: $env"
doneBrace ranges and sequence expressions
Brace ranges are Bash's compact way to count. The three useful forms:
# Simple range
for i in {1..10}; do echo "$i"; done
# With step (Bash 4+)
for i in {0..20..2}; do echo "$i"; done # 0, 2, 4, ..., 20
# Letter range
for letter in {a..z}; do echo "$letter"; done
# Zero-padded
for i in {01..10}; do echo "$i"; done # 01, 02, ..., 10The step form ({0..20..2}) is Bash 4+ only — it silently fails on macOS's default Bash 3.2. See the macOS gotcha section for the workaround.
For dynamic ranges (where N comes from a variable), brace expansion does NOT work — {1..$N} expands literally. Use seq instead:
end=10
for i in $(seq 1 $end); do echo "$i"; done
# Or the C-style form, which works without seq:
for ((i=1; i<=end; i++)); do echo "$i"; doneseq is technically not built into Bash (it's a separate GNU coreutils binary), but it's installed on every Linux distribution and on macOS by default.
Iterating over arrays
fruits=("apple" "banana" "cherry")
for fruit in "${fruits[@]}"; do
echo "Fruit: $fruit"
doneThe quotes and [@] matter. Three common variants and what they do:
| Expansion | Behavior |
|---|---|
"${fruits[@]}" | Expands to one quoted word per element. What you want. |
${fruits[@]} | Expands to space-separated unquoted words. Breaks on elements containing spaces. |
"${fruits[*]}" | Expands to ONE quoted string with all elements joined by $IFS. Wrong for loops. |
${fruits[*]} | Like [@] unquoted, plus joined by $IFS. Almost never what you want. |
The rule: "${array[@]}" (with the double quotes) is the only correct form for iterating an array. Memorise this — the unquoted form is the #1 source of "but it worked on my machine" Bash bugs.
To get the indexes instead of the values:
for i in "${!fruits[@]}"; do
echo "$i: ${fruits[$i]}"
doneAssociative arrays (Bash 4+):
declare -A versions=([node]=20 [python]=3.12 [go]=1.22)
for tool in "${!versions[@]}"; do
echo "$tool: ${versions[$tool]}"
doneIterating over files (globs)
for file in *.txt; do
echo "Processing $file"
doneBash expands the glob *.txt to a list of matching filenames BEFORE the loop runs. If there are 50 matching files, the loop runs 50 times. If there are zero matching files, by default Bash leaves the glob as the literal string *.txt — so the loop runs once with $file = "*.txt". That's almost always a bug.
Three ways to handle the zero-match case:
# A. Check existence first
for file in *.txt; do
[ -e "$file" ] || continue
echo "Processing $file"
done
# B. Enable nullglob (recommended for scripts)
shopt -s nullglob
for file in *.txt; do
echo "Processing $file"
done
shopt -u nullglob # restore default
# C. Loop with find (handles subdirectories too)
while IFS= read -r -d '' file; do
echo "Processing $file"
done < <(find . -name "*.txt" -print0)shopt -s nullglob makes Bash silently expand non-matching globs to nothing, so the loop body runs zero times instead of once with the literal pattern. It's a process-wide setting — turn it back off after the loop if other code depends on the default behavior.
For practical batch-file workflows that use the glob pattern, see How to ZIP Multiple Directories into Individual Files and How to Optimize JPEG Images Using jpegoptim — both lean on for file in *.ext; do ...; done as the iteration backbone.
C-style for loop
for ((i=1; i<=10; i++)); do
echo "Step $i"
doneThree expressions inside the (( )):
- init — runs once before the loop (
i=1) - test — checked before each iteration; loop continues while it's true (
i<=10) - increment — runs after each iteration (
i++)
This is the Bash equivalent of C's for(...;...;...). Useful when you need arithmetic that doesn't fit a brace range — non-uniform steps, conditions that depend on other variables, or pre/post-decrement.
# Countdown
for ((i=10; i>0; i--)); do echo "$i..."; done
# Even numbers only, dynamic upper bound
upper=20
for ((i=0; i<=upper; i+=2)); do echo "$i"; done
# Two variables in lockstep
for ((i=0, j=10; i<5; i++, j--)); do
echo "i=$i, j=$j"
doneNote: variables inside (( )) don't need the $ prefix — i++ works, $i++ doesn't.
Nested for loops
for i in {1..3}; do
for j in {A..C}; do
echo "$i$j"
done
doneOutput: 1A 1B 1C 2A 2B 2C 3A 3B 3C (one per line). Each inner-loop iteration completes before the outer loop advances.
Practical example — generating a multiplication table:
for i in {1..5}; do
for j in {1..5}; do
printf "%3d " $((i * j))
done
echo # newline after each row
doneFor deeper nesting, break N (covered below) exits N levels at once.
Safe file iteration with spaces and newlines
The naive pattern below is broken for filenames containing spaces or newlines:
# ❌ BAD — breaks on filenames with spaces
for file in $(find . -name "*.txt"); do
echo "$file"
doneWhy: command substitution $(...) returns a string, which Bash then word-splits on $IFS (spaces, tabs, newlines by default). My File.txt becomes two "files": My and File.txt.
The correct patterns:
# ✅ A. find -print0 + while-read with NUL delimiter
while IFS= read -r -d '' file; do
echo "Processing: $file"
done < <(find . -name "*.txt" -print0)
# ✅ B. mapfile (Bash 4+) into an array, then loop
mapfile -d '' -t files < <(find . -name "*.txt" -print0)
for file in "${files[@]}"; do
echo "Processing: $file"
done
# ✅ C. Simple glob if files are in the current directory only
shopt -s nullglob
for file in *.txt; do
echo "Processing: $file"
done
shopt -u nullglobThe -print0 / -d '' pair uses the NUL byte as a separator. NUL can't appear in a filename, so it's the only safe delimiter.
Avoid the legacy IFS=$'\n' workaround — it handles spaces but breaks on newlines in filenames (yes, filenames can contain newlines on Unix).
break and continue
continue skips to the next iteration. break exits the loop entirely. Both accept an optional level number for nested loops.
# Skip even numbers
for i in {1..10}; do
if (( i % 2 == 0 )); then
continue
fi
echo "Odd: $i"
done
# Stop at the first match
for file in *.log; do
if grep -q "ERROR" "$file"; then
echo "First error file: $file"
break
fi
done
# break out of both loops at once (break 2)
for i in {1..3}; do
for j in {1..3}; do
if (( i*j == 6 )); then
echo "Stopping at $i × $j"
break 2
fi
done
donebreak 2 exits the outer loop directly. Without the level, break only exits the inner loop.
Parallel execution
By default, a for loop is sequential — each iteration finishes before the next begins. For independent operations (image processing, file conversion, HTTP requests), parallelism is a big speedup.
# Run each iteration in the background, wait for all
for url in $(cat urls.txt); do
curl -sO "$url" &
done
waitThe & puts each curl in the background; wait blocks until every backgrounded job finishes. Caveat: this spawns one process per URL with no concurrency cap — fine for 10 URLs, dangerous for 10,000.
For a controlled parallelism level, use xargs -P:
# 8 parallel downloads
cat urls.txt | xargs -P 8 -n 1 curl -sO-P 8 runs at most 8 jobs at a time; -n 1 passes one argument per invocation. This is the idiom I use most for batch processing — predictable, doesn't fork-bomb the machine, and the parallelism level is a flag.
For more sophisticated control (rate limiting, retries, structured output), GNU parallel is the heavier tool.
Common pitfalls
1. Unquoted array expansion. for x in ${arr[@]} (no quotes) breaks on elements containing spaces. Always use "${arr[@]}".
2. Zero-match globs. for file in *.txt runs once with $file = "*.txt" if no .txt files exist. Use shopt -s nullglob or [ -e "$file" ] || continue.
3. $(find ...) word splitting. Splits on whitespace, breaking filenames with spaces. Use find -print0 | while read -d '' file.
4. Modifying the loop variable doesn't affect later iterations. for i in 1 2 3; do i=99; ...; done — the next iteration still gets the original value.
5. Subshell scoping. Variables set inside ... | while read x; do VAR=...; done are lost after the loop. The pipe creates a subshell. For-loops over a glob or array don't have this problem — they run in the current shell.
6. Brace ranges aren't dynamic. for i in {1..$N} does NOT expand the variable. Use seq or the C-style form.
7. Glob injection in filenames. A file named -rf could break for file in *; do rm "$file"; done if you forget the quotes. Always quote "$file". Better: use -- to terminate flags: rm -- "$file".
8. Forgetting done. Bash gives a confusing syntax error near unexpected token if you close the loop with } or end. Bash uses done.
9. Reading lines from a file with for. for line in $(cat file) word-splits on whitespace, NOT on lines. Use while IFS= read -r line; do ...; done < file instead.
macOS Bash 3.2 vs Linux Bash 4+
macOS ships with Bash 3.2 (frozen since 2007 due to GPLv3 licensing). Linux distros ship Bash 4 or 5. Three things only work on Bash 4+:
| Feature | Bash 3.2 (macOS default) | Bash 4+ (Linux, brew) |
|---|---|---|
Stepped brace ranges {0..20..2} | literal {0..20..2} | works as expected |
Associative arrays declare -A | error | works |
mapfile / readarray | not available | works |
${var,,} lowercasing | error | works |
If your script needs Bash 4+ features and might run on macOS, either install Bash via Homebrew (brew install bash, gives you 5.x at /opt/homebrew/bin/bash or /usr/local/bin/bash) or write a portable fallback:
# Works on Bash 3.2 and 4+
for ((i=0; i<=20; i+=2)); do echo "$i"; done # instead of {0..20..2}
seq 0 2 20 # alternativeIf you can require Bash 4+, the shebang should reflect that:
#!/usr/bin/env bash
# Require Bash 4+
if (( BASH_VERSINFO[0] < 4 )); then
echo "This script requires Bash 4.0 or later. Current: $BASH_VERSION" >&2
exit 1
fiRelated: while, until, select
Bash has three sibling loop constructs to for:
while— runs while a condition is true. The right choice when you don't know the number of iterations in advance. See Bash While Loops for the full reference.until— runs while a condition is false (inverse ofwhile). Mostly used for retry-until-success patterns.select— interactive menu loop that prompts the user to choose from a list of options. Useful for interactive scripts.
# while
i=1
while (( i <= 10 )); do
echo "$i"
((i++))
done
# until
until curl -sf "http://localhost:3000/health"; do
sleep 1
done
# select
select choice in start stop restart quit; do
case $choice in
start) systemctl start myapp ;;
stop) systemctl stop myapp ;;
restart) systemctl restart myapp ;;
quit) break ;;
esac
doneFor loop choice, the rule of thumb: known count or known list → for; condition-driven → while; "do this thing until it succeeds" → until; interactive menu → select.
Debugging Bash for loops
# Trace every command and its arguments
bash -x script.sh
# Trace only a section
set -x
for file in *.txt; do
process "$file"
done
set +x
# Stop on error + treat unset variables as errors + fail pipes
set -euo pipefail
# Static analysis
shellcheck script.shshellcheck (installable via brew install shellcheck or apt install shellcheck) catches every common Bash pitfall described above — unquoted variables, missing --, word-splitting bugs — at lint time. Run it on every script you write.
For Bash scripting in production scenarios specifically:
- The Tor New Circuit (NEWNYM) Control Port walkthrough uses
forloops to issue NEWNYM signals in batches. - The How to Export All MySQL Databases cron-friendly backup script demonstrates
forover database lists withfind -mtimefor log pruning. - The How to Export and Import PuTTY Settings guide pairs Bash with
regeditfor cross-platform automation.
What to do next
- Bash While Loops — the condition-driven sibling. Same scoping rules, different control structure.
- How to ZIP Multiple Directories into Individual Files — a worked example of
for dir in */; do ... donewithzipfor batch archiving. - How to Optimize JPEG Images Using jpegoptim — batch image optimisation using
for file in *.jpg. - Tor New Circuit (NEWNYM) Control Port — for loops to drive Tor through a control-port script.
- External: the Bash Reference Manual: Looping Constructs is the authoritative spec.



![Bash while loop reference: read files with while IFS= read -r, retry-with-backoff, wait-for-service polling, the subshell-scoping bug fix, the until and select siblings, plus [[ ]] vs [ ] vs (( )) test contexts.](https://techearl.com/cdn-cgi/image/width=1536,format=auto,quality=90/https://images.techearl.com/bash-while-loop/bash-while-loop.jpg?v=2026-04-25T13%3A18%3A00Z)
![Bash conditionals reference: if/elif/else syntax, [ ] vs [[ ]] vs (( )) test contexts, numeric, string, and file operators, the case statement, and the unquoted-variable pitfall.](https://techearl.com/cdn-cgi/image/width=1536,format=auto,quality=90/https://images.techearl.com/bash-if-else/bash-if-else.jpg?v=2026-01-08T11%3A24%3A00Z)
