Linux / Bash

Bash Script Variable Lost Inside while read Loop Due to Pipe Subshell

A variable that appears correctly updated inside a while read loop, but reverts back to its original value the moment the loop finishes, is one of the most common and specific Bash gotchas — caused by the loop running in a subshell whenever it's fed data through a pipe, which gives it its own separate copy of every variable.

The Problem

Code that reads lines from a command's output and accumulates a result appears to work correctly during the loop, but the final value is wrong once the loop completes:

total=0
grep "ERROR" logfile.txt | while read -r line; do
    total=$((total + 1))
    echo "Running total: $total"  # this correctly shows 1, 2, 3...
done
echo "Final total: $total"  # prints 0, not the actual count

The running total printed inside the loop is completely correct, which makes the final, reset value outside the loop especially confusing.

Why It Happens

In Bash, any command on the receiving end of a pipe runs in a subshell — a separate child process with its own independent copy of the parent shell's variables. The while read loop here is exactly that: the receiving end of the grep | while read pipe. Every change made to total inside the loop happens only within that subshell's own private copy — completely correctly, from the subshell's own point of view — but once the subshell exits (when the loop finishes), that copy and all its changes are discarded entirely, leaving the parent shell's original total variable exactly as it was before the loop ever ran.

The Fix

The standard, most direct fix is avoiding the pipe entirely by using input redirection instead, which keeps the while read loop running in the current shell rather than a subshell:

total=0
while read -r line; do
    if [[ "$line" == *"ERROR"* ]]; then
        total=$((total + 1))
    fi
done < logfile.txt
echo "Final total: $total"  # correctly reflects the accumulated count

Notice the file is now read directly via < logfile.txt rather than piped in from a separate grep command — this avoids creating a subshell, since there's no pipe involved, so the loop runs in the same shell as the rest of the script and correctly modifies the same total variable throughout.

If you genuinely need to filter or transform the input first (as the original example's grep was doing), use process substitution instead of a plain pipe, which achieves the same filtering effect while still avoiding the subshell problem:

total=0
while read -r line; do
    total=$((total + 1))
done < <(grep "ERROR" logfile.txt)
echo "Final total: $total"

The syntax < <(command) feeds the command's output to the loop as if it were a file being redirected in, rather than piped, sidestepping the subshell issue entirely while still letting you filter or transform the input with any command you need beforehand.

If restructuring away from a pipe genuinely isn't practical for a specific script's structure, an alternative workaround is writing the accumulated value to a temporary file from inside the subshell, then reading it back in the parent shell after the loop completes:

grep "ERROR" logfile.txt | while read -r line; do
    total=$((total + 1))
    echo "$total" > /tmp/count.txt
done
total=$(cat /tmp/count.txt)
echo "Final total: $total"

This is less elegant than avoiding the subshell in the first place, but is a reasonable fallback when the pipe genuinely can't be restructured for some specific reason in a particular script.

Still Not Working?

If you're on Bash 4.2 or later, the lastpipe shell option offers another way to keep the original pipe syntax while avoiding the subshell, specifically for the last command in a pipeline, when job control is disabled (which it normally is inside a non-interactive script):

shopt -s lastpipe
total=0
grep "ERROR" logfile.txt | while read -r line; do
    total=$((total + 1))
done
echo "Final total: $total"

This preserves the original, arguably more readable pipe-based syntax while still correctly updating the parent shell's variables, though be aware it depends on the specific Bash version and non-interactive execution context, so verify it behaves as expected in your actual target environment (a cron job, a CI runner, a specific deployment environment) before relying on it, since interactive shell sessions and some execution contexts handle lastpipe differently.