Fish Scripting: Loops Complete Guide
Loops are fundamental constructs in any scripting language, and Fish shell offers a refreshingly clean and readable approach to iteration. Unlike Bash's arcane syntax, Fish loops are designed to be intuitive, self-documenting, and easy to compose. This guide covers every loop type in Fish, from basic iteration to advanced patterns, with complete working examples you can drop directly into your scripts.
What Are Fish Loops?
Loops in Fish allow you to repeat a block of code multiple times — iterating over lists of values, processing command output line by line, or running a block while a condition holds true. Fish provides two primary loop constructs: for loops for iteration over known collections, and while loops for condition-driven repetition. Both integrate seamlessly with Fish's list-oriented design, where variables naturally hold multiple values without needing arrays in the traditional sense.
Why Fish Loops Matter
Fish loops eliminate the cryptic quoting and word-splitting pitfalls that plague POSIX shells. In Bash, iterating over filenames with spaces requires careful IFS manipulation and quoting gymnastics. Fish handles this naturally — each list element is a distinct, intact value. This means fewer bugs, more readable scripts, and dramatically reduced cognitive overhead. Additionally, Fish loops work harmoniously with the shell's color-coded syntax highlighting and autosuggestion features, giving you real-time feedback as you write.
- No word splitting — list elements stay whole regardless of spaces or special characters
- Clean syntax — no semicolons, no
do/donekeywords, just natural line breaks - Universal iteration — loop over variables, globs, command output, or brace expansions with the same simple syntax
- Predictable scoping — loop index variables are local to the loop block by default
The for Loop: Iterating Over Lists
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →The for loop is Fish's workhorse. It iterates over each element in a list, binding the current element to a variable you name. The syntax is remarkably straightforward:
for VARIABLE in LIST
# commands using $VARIABLE
end
Everything between for and end executes once per list element. Let's start with the simplest example:
for color in red green blue yellow
echo "The current color is: $color"
end
Output:
The current color is: red
The current color is: green
The current color is: blue
The current color is: yellow
Iterating Over Variables
Since Fish variables can hold multiple values, you can iterate directly over a variable's contents:
set fruits apple banana cherry date
for fruit in $fruits
echo "Processing fruit: $fruit"
end
Fish automatically expands $fruits into its individual elements — no array indexing, no [@] syntax, no fuss. Each element is passed intact to the loop body.
Iterating Over Globs (Filename Expansion)
Glob patterns expand into lists of matching filenames, making file iteration trivially easy:
# Process all .txt files in the current directory
for file in *.txt
echo "Found text file: $file"
wc -l "$file"
end
Fish handles filenames with spaces, newlines, or special characters correctly — each glob match is a single, unbroken value.
You can combine multiple globs in a single loop:
for file in *.jpg *.png *.gif
echo "Resizing image: $file"
# convert "$file" -resize 800x800 "thumb_$file"
end
Iterating Over Brace Expansions
Fish supports brace expansion, which generates a list of strings from a pattern:
for num in {1..10}
echo "Number: $num"
end
You can also use step values and zero-padding:
# From 0 to 100 in steps of 5
for percent in {0,5,10,15,20,25,30,35,40,45,50,55,60,65,70,75,80,85,90,95,100}
echo "Progress: $percent%"
end
# Using step notation
for num in (seq 0 5 100)
echo "Progress: $num%"
end
Iterating Over Command Substitutions
Command substitutions in Fish return lists, making it natural to loop over command output. Each line of output becomes a separate list element:
# List all directories in the current path
for dir in (ls -d */)
echo "Directory: $dir"
end
# Process all running Docker containers
for container in (docker ps -q)
echo "Inspecting container: $container"
docker inspect "$container"
end
Important: When command output contains spaces within lines (not between lines), each line is still one element. To split on characters other than newlines, use the string split command:
# Split on commas
set csv_data "apple,banana,cherry,date"
for item in (string split ',' "$csv_data")
echo "Item: $item"
end
Using Multiple Iterators (Parallel Lists)
Fish does not have a built-in zip-like syntax, but you can achieve parallel iteration using index-based access:
set names Alice Bob Carol
set scores 95 87 92
set index 1
for name in $names
set score $scores[$index]
echo "$name scored $score"
set index (math $index + 1)
end
Output:
Alice scored 95
Bob scored 87
Carol scored 92
The while Loop: Condition-Driven Repetition
The while loop executes its body repeatedly as long as a condition command returns an exit status of 0 (success). The syntax:
while CONDITION_COMMAND
# commands to repeat
end
Simple Counter with while
set counter 1
while test $counter -le 5
echo "Iteration: $counter"
set counter (math $counter + 1)
end
Output:
Iteration: 1
Iteration: 2
Iteration: 3
Iteration: 4
Iteration: 5
Reading Lines from a File
The read command is perfect as a while condition — it returns success as long as it can read more data:
while read -l line
echo "Line: $line"
end < data.txt
This reads every line from data.txt. The -l flag tells read to read exactly one line. Without it, read would consume input differently. You can also pipe input:
cat access.log | while read -l log_line
if string match -q "ERROR" "$log_line"
echo "Error line found: $log_line"
end
end
Infinite Loops with Break Conditions
For an intentional infinite loop, use true as the condition and break to exit:
set attempts 0
while true
set attempts (math $attempts + 1)
echo "Attempt $attempts..."
# Simulate checking something
if test $attempts -ge 5
echo "Success after $attempts attempts!"
break
end
sleep 1
end
Complex Conditions with begin/end
When your while condition requires multiple commands, wrap them in begin/end and use ; to chain. The last command's exit status determines whether the loop continues:
set counter 1
while begin
echo "Checking iteration $counter..."
test $counter -le 3
end
echo "Inside loop body: $counter"
set counter (math $counter + 1)
end
Output:
Checking iteration 1...
Inside loop body: 1
Checking iteration 2...
Inside loop body: 2
Checking iteration 3...
Inside loop body: 3
Checking iteration 4...
break and continue: Controlling Loop Flow
Fish provides break and continue to control loop execution from within the body. These work identically in both for and while loops.
break — Exit the Loop Entirely
break immediately terminates the innermost loop:
for file in *.log *.txt *.csv
if test ! -r "$file"
echo "Cannot read $file, stopping."
break
end
echo "Processing readable file: $file"
end
continue — Skip to the Next Iteration
continue skips the rest of the current iteration and proceeds to the next element or condition check:
for file in *
# Skip directories
if test -d "$file"
echo "Skipping directory: $file"
continue
end
echo "Processing file: $file"
end
Breaking Out of Nested Loops
By default, break exits only the innermost loop. To break out of multiple nesting levels, pass an integer argument:
for category in fruits vegetables
echo "Category: $category"
for item in apple carrot banana broccoli
if string match -q "broccoli" "$item"
echo "Found broccoli — stopping everything!"
break 2
end
echo " Item: $item"
end
end
Output:
Category: fruits
Item: apple
Item: carrot
Item: banana
Category: vegetables
Item: apple
Item: carrot
Item: banana
Found broccoli — stopping everything!
Similarly, continue 2 would skip the current iteration of the outer loop.
Nested Loops: Combining for and while
Fish loops compose naturally. You can nest for inside while, while inside for, or any combination:
# Process each user's log files
set users alice bob carol
for user in $users
echo "===== Processing user: $user ====="
set log_count 0
while read -l log_line
if string match -q "ERROR" "$log_line"
set log_count (math $log_count + 1)
end
end < /var/log/app/$user.log
echo "Found $log_count errors for $user"
end
Generating Combinations with Nested Loops
for prefix in A B C
for suffix in 1 2 3
echo "$prefix$suffix"
end
end
Output:
A1
A2
A3
B1
B2
B3
C1
C2
C3
Advanced Patterns and Techniques
Collecting Loop Results into a Variable
Use set with the loop to accumulate results. The -a flag appends to a list variable:
set error_files
for log in *.log
if grep -q "CRITICAL" "$log"
set -a error_files "$log"
end
end
if test -n "$error_files"
echo "Files with critical errors:"
printf '%s\n' $error_files
else
echo "No critical errors found."
end
Looping with Index Numbers
Sometimes you need both the value and its position. Use a counter variable:
set servers web01 web02 db01 cache01
set i 1
for server in $servers
echo "Server #$i: $server"
set i (math $i + 1)
end
Alternatively, iterate over sequence numbers and index into the list:
set servers web01 web02 db01 cache01
set total (count $servers)
for i in (seq 1 $total)
echo "Server $i/$total: $servers[$i]"
end
Filtering During Iteration
Combine string match with loops to filter on the fly:
for item in *.log *.bak *.tmp *.conf
# Only process .conf and .log files
if not string match -qr '\.(conf|log)$' "$item"
echo "Skipping: $item"
continue
end
echo "Processing config/log: $item"
end
Pipelines Inside Loop Bodies
Loop bodies can contain full pipelines. Each command in the pipeline inherits the loop's variable scope:
for domain in (cat domains.txt)
echo "Checking $domain..."
curl -sI "https://$domain" | string match -r "HTTP/[\d.]+ \d+" | string split " "
end
Using Functions Inside Loops
Define reusable logic as functions and call them from loops. This keeps your scripts modular:
function format_bytes --argument bytes
if test $bytes -gt 1073741824
printf "%.1f GB" (math "scale=1; $bytes/1073741824")
else if test $bytes -gt 1048576
printf "%.1f MB" (math "scale=1; $bytes/1048576")
else
echo "$bytes bytes"
end
end
for file in *
set size (stat -c %s "$file")
echo "$file: "(format_bytes $size)
end
Handling Empty Lists Gracefully
Fish handles empty lists correctly — if there are no elements, the loop body simply doesn't execute. This prevents the "loop runs once with an empty variable" bug common in Bash:
# If no .xyz files exist, this loop runs zero times — no error
for file in *.xyz
echo "Processing: $file"
end
echo "Done (no .xyz files found, loop skipped cleanly)"
Common Use Cases
Batch File Renaming
for file in *.jpg
set new_name (string replace "IMG_" "Photo_" "$file")
if test "$file" != "$new_name"
mv -v "$file" "$new_name"
end
end
Bulk Operations on Remote Hosts
set hosts (cat ~/.ssh/config | string match -r "^Host \w+" | string replace "Host " "")
for host in $hosts
echo "Updating packages on $host..."
ssh "$host" 'sudo apt update && sudo apt upgrade -y'
echo "Done with $host"
end
Monitoring Loop with Timeout
set timeout (math (date +%s) + 60) # 60 seconds from now
while test (date +%s) -lt $timeout
set status (curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/health)
if test "$status" = "200"
echo "Service is healthy!"
break
end
echo "Waiting for service... (got $status)"
sleep 2
end
if test (date +%s) -ge $timeout
echo "Timed out waiting for service" >&2
return 1
end
Processing JSON Lines (NDJSON)
while read -l json_line
set id (echo "$json_line" | jq -r '.id')
set name (echo "$json_line" | jq -r '.name')
echo "Record $id: $name"
end < records.ndjson
Best Practices
- Quote variables in commands — Always use
"$variable"when passing loop variables to commands that expect single arguments. Fish preserves the value, but the quotes protect against empty values causing argument errors. - Use descriptive loop variable names —
for file in *.txtreads much better thanfor f in *.txt. Fish's autosuggestions work better with meaningful names. - Prefer for over while when the collection is known — If you have a fixed list to process,
forcommunicates intent more clearly than awhilewith manual iteration. - Handle the empty-list case — Fish naturally skips empty lists, but consider adding an explicit check if the emptiness is meaningful:
if not count $files >/dev/null; echo "No files"; return; end - Keep loop bodies focused — Extract complex logic into functions. A loop body should ideally fit on screen without scrolling.
- Use begin/end for multi-command conditions — In while loops, wrap compound conditions in
begin/endblocks for clarity. - Leverage string split for non-line delimiters — When command output uses tabs, commas, or custom delimiters, pipe through
string splitbefore the loop. - Avoid modifying the iterated list mid-loop — If you need to collect results, use
set -ato append to a separate accumulator variable rather than modifying the list you're iterating over. - Use break with a numeric argument sparingly —
break 2andbreak 3work, but they make code harder to reason about. Consider restructuring deeply nested loops or using early returns from functions instead.
Performance Considerations
Fish loops are generally fast enough for interactive use and typical scripting tasks. However, for extremely tight loops (tens of thousands of iterations), the overhead of spawning subprocesses inside the loop body can become noticeable. In those cases:
- Minimize external command calls inside the loop — use Fish builtins like
string,math, andtestinstead ofgrep,expr, or[. - Batch operations where possible — process multiple items at once rather than one per loop iteration.
- Consider using
xargsor a dedicated tool likeparallelfor CPU-intensive batch processing across many items.
Conclusion
Fish loops represent a thoughtful evolution of shell iteration syntax. By eliminating word splitting, using natural line-based block structure, and treating lists as first-class citizens, Fish makes loops predictable and pleasant to write. The for loop handles the vast majority of iteration tasks with elegance, while while provides the flexibility needed for condition-driven repetition, streaming input, and monitoring patterns. Together with break, continue, and Fish's rich built-in commands, you have everything needed to express complex iteration logic without the sharp edges of traditional shell scripting. The best way to master Fish loops is to use them — start replacing your Bash iteration patterns with Fish equivalents, and you'll quickly discover how much cognitive load the cleaner syntax removes from your daily scripting workflow.