← Back to DevBytes

Fish Scripting: Loops Complete Guide

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.

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

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:

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles