TechEarl

Bash For Loops: Syntax, Examples, and One-Liners

Every form of the Bash for loop with working examples: brace-range, sequence-expression, array, glob, C-style, nested, and parallel. Plus the safe file-iteration patterns, common pitfalls, and macOS Bash 3.2 vs Linux Bash 4+ gotchas.

Ishan KarunaratneIshan Karunaratne⏱️ 4 min readUpdated
Bash for loop reference: brace-range {1..10}, sequence (seq), array, glob, C-style, nested, parallel with xargs. Plus safe file iteration with find -print0, globbing pitfalls, and macOS Bash 3.2 vs Linux Bash 4+ differences.

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

bash
# 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"; done

These 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

bash
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 do and done runs once per item.

A literal list:

bash
for color in red blue green; do
    echo "Color: $color"
done

A list from command substitution:

bash
for user in $(cut -d: -f1 /etc/passwd); do
    echo "User: $user"
done

A list from a brace expansion:

bash
for env in {dev,staging,prod}; do
    echo "Env: $env"
done

Brace ranges and sequence expressions

Brace ranges are Bash's compact way to count. The three useful forms:

bash
# 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, ..., 10

The 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:

bash
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"; done

seq 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

bash
fruits=("apple" "banana" "cherry")
for fruit in "${fruits[@]}"; do
    echo "Fruit: $fruit"
done

The quotes and [@] matter. Three common variants and what they do:

ExpansionBehavior
"${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:

bash
for i in "${!fruits[@]}"; do
    echo "$i: ${fruits[$i]}"
done

Associative arrays (Bash 4+):

bash
declare -A versions=([node]=20 [python]=3.12 [go]=1.22)
for tool in "${!versions[@]}"; do
    echo "$tool: ${versions[$tool]}"
done

Iterating over files (globs)

bash
for file in *.txt; do
    echo "Processing $file"
done

Bash 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:

bash
# 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

bash
for ((i=1; i<=10; i++)); do
    echo "Step $i"
done

Three 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.

bash
# 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"
done

Note: variables inside (( )) don't need the $ prefix — i++ works, $i++ doesn't.

Nested for loops

bash
for i in {1..3}; do
    for j in {A..C}; do
        echo "$i$j"
    done
done

Output: 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:

bash
for i in {1..5}; do
    for j in {1..5}; do
        printf "%3d " $((i * j))
    done
    echo  # newline after each row
done

For 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:

bash
# ❌ BAD — breaks on filenames with spaces
for file in $(find . -name "*.txt"); do
    echo "$file"
done

Why: 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:

bash
# ✅ 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 nullglob

The -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.

bash
# 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
done

break 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.

bash
# Run each iteration in the background, wait for all
for url in $(cat urls.txt); do
    curl -sO "$url" &
done
wait

The & 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:

bash
# 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+:

FeatureBash 3.2 (macOS default)Bash 4+ (Linux, brew)
Stepped brace ranges {0..20..2}literal {0..20..2}works as expected
Associative arrays declare -Aerrorworks
mapfile / readarraynot availableworks
${var,,} lowercasingerrorworks

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:

bash
# 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                                       # alternative

If you can require Bash 4+, the shebang should reflect that:

bash
#!/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
fi

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 of while). 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.
bash
# 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
done

For 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

bash
# 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.sh

shellcheck (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:

What to do next

FAQ

TagsBashShell ScriptingLinuxCommand LineAutomationfor loopGlobbing
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

Regex lookaheads and lookbehinds assert what comes before or after a match without consuming characters. Full reference with syntax, password validation, variable-width vs fixed-width support per engine, and examples in JavaScript, Python, PHP, Go, Java, .NET.

How to Use Regex Lookaheads and Lookbehinds

Regex lookaheads and lookbehinds assert what comes before or after a match without consuming characters. Full reference with syntax, password validation, variable-width vs fixed-width support per engine, and examples in JavaScript, Python, PHP, Go, Java, .NET.