Bash loops are one of those foundational tools you reach for constantly. Need to process files in bulk? Retry something with backoff? Rename 50 photos with sequential numbers? A solid loop is your best friend. Let’s cover the most practical patterns you’ll actually use, from simple ranges to stepping, zero-padding, and real-world scenarios.
The Classic Range Loop
Start simple. Count from 1 to 100:
for i in {1..100}; do echo $idoneThat brace expansion {1..100} is pure Bash and super efficient. Add a timestamp to each output:
for i in {1..100}; do echo "$(date +"%D %I:%M:%S") - $i"doneStepping Through a Range
Every 3rd number instead of every 1:
for i in {1..100..3}; do echo "$(date +"%D %I:%M:%S") - $i"doneThe third parameter is the step size. Want every 5th? Use {1..100..5}.
The seq Command
Before brace expansion, there was seq. It’s handy for dynamic ranges:
# Count from 1 to 100seq 1 100
# Every 2nd numberseq 1 2 100
# Backwardsseq 100 -1 1
# Zero-padded output (useful for filenames)seq -w 1 100With seq -w, you get 001, 002, 003...100 instead of 1, 2, 3...100. That’s huge for file sorting.
C-Style for Loops
If you need more control, go C-style:
for ((i=1; i<=100; i++)); do echo $idone
# Every 2nd numberfor ((i=1; i<=100; i+=2)); do echo $idone
# Countdown from 100 to 1for ((i=100; i>=1; i--)); do echo $idoneThis syntax is familiar to anyone who’s touched C, Java, or JavaScript. Use it when you need conditional increments or complex logic.
While Loops with Counters
A while loop with a manual counter gives you maximum flexibility:
i=1while [ $i -le 100 ]; do echo $i ((i++))doneOr countdown:
i=10while [ $i -gt 0 ]; do echo "Retry in $i seconds..." sleep 1 ((i--))doneecho "Go!"Real-World Example: Batch File Renaming with Counters
Say you have 50 JPG files from your camera and want to rename them sequentially:
#!/bin/bashcounter=1for file in *.jpg; do newname=$(printf "photo_%03d.jpg" $counter) mv "$file" "$newname" ((counter++))doneThe printf "photo_%03d.jpg" gives you photo_001.jpg, photo_002.jpg, etc. — padded with zeros so they sort correctly. Without zero-padding, photo_10.jpg comes before photo_2.jpg alphabetically. Gross.
Practical Example: Retry Loop with Countdown
Deploying something flaky? Retry with a countdown between attempts:
#!/bin/bashmax_attempts=5attempt=1
while [ $attempt -le $max_attempts ]; do if curl -s https://api.example.com/health > /dev/null; then echo "Health check passed!" exit 0 fi
if [ $attempt -lt $max_attempts ]; then countdown=5 while [ $countdown -gt 0 ]; do echo "Attempt $attempt failed. Retrying in $countdown seconds..." sleep 1 ((countdown--)) done fi
((attempt++))done
echo "All $max_attempts attempts failed."exit 1Looping Over Arrays with Counting
Sometimes you need both the index and the value:
fruits=("apple" "banana" "cherry" "date")
# Old school: C-style loopfor ((i=0; i<${#fruits[@]}; i++)); do echo "$i: ${fruits[$i]}"done
# Newer bash: loop with index trackingi=0for fruit in "${fruits[@]}"; do echo "$i: $fruit" ((i++))doneOutput: 0: apple, 1: banana, etc.
Progress Percentage Counter
Calculating progress for a long-running loop:
#!/bin/bashtotal=100
for ((i=1; i<=total; i++)); do # Do work here sleep 0.1
# Calculate percentage percent=$((i * 100 / total)) printf "\rProgress: %d%%" $percentdone
echo ""echo "Done!"The \r carriage return overwrites the same line, giving you a nice progress ticker without spamming your terminal.
Key Takeaways
- Brace expansion
{1..100}is fast and clean for simple ranges - seq shines when you need zero-padding or dynamic ranges
- C-style loops are familiar and flexible for complex logic
- While loops excel when you need granular counter control
- Zero-padding with
seq -worprintf "%03d"prevents file sorting disasters - Nested loops (loop + countdown) handle retry/backoff patterns beautifully
Pick the right tool for your use case. A simple range loop doesn’t need C-style syntax. But for retries, progress tracking, or countdown logic? That’s where while loops and C-style loops earn their keep. Your future self maintaining this script will appreciate clarity over cleverness.
The Subshell Gotcha That Will Ruin Your Day
Here’s one that trips up everyone at least once. You pipe something into a loop and wonder why your counter is still zero when the loop exits:
count=0cat files.txt | while read line; do ((count++))doneecho "Processed: $count" # Prints 0. Every time. Why.The pipe creates a subshell. Your while loop runs inside it, increments count, then that subshell dies and takes your counter with it. The outer shell never saw a thing.
Fix it two ways. First, use process substitution instead of a pipe:
count=0while read line; do ((count++))done < <(cat files.txt)echo "Processed: $count" # Actually works nowThe < <(...) syntax redirects from a process substitution — it looks weird but it keeps everything in the current shell. Second option: just use a temp file if you need compatibility with older bash:
count=0cat files.txt > /tmp/lines.txtwhile read line; do ((count++))done < /tmp/lines.txtrm /tmp/lines.txtecho "Processed: $count"Ugly but portable. The same gotcha applies to variables set inside any piped loop — grep ... | while, find ... | while, all of them. If you’re wondering why a variable isn’t updating, subshell scope is the first thing to check.
One more: ((count++)) returns exit code 1 when count is 0 (because the expression evaluates to zero, which is falsy). If you’re running with set -e (exit on error), that will kill your script before it starts. Use ((count++)) || true or count=$((count + 1)) instead.