Skip to main content

Loop variable collisions in Bash

Let's have a look at this simple Bash script:

#! /bin/bash
inner() {
    for i in {1..4}; do
        echo "  $i"
    done
}

outer() {
    for i in {1..3}; do
        echo "$i"
        inner
    done
}

outer

The output is ..

1
  1
  2
  3
  4
2
  1
  2
  3
  4
3
  1
  2
  3
  4

.. as expected. Now if we turn the for loops to C-style, we end up with this code:

#! /bin/bash
inner() {
    for ((i = 1; i <= 4; i++)); do
        echo "  $i"
    done
}

outer() {
    for ((i = 1; i <= 3; i++)); do
        echo "$i"
        inner
    done
}

outer

Before you continue, take a moment: What output do you expect? The same? The output we get is:

1
  1
  2
  3
  4

Why? By default, in Bash variables a global unless declared local, explicitly. That includes loop variables. The initial code had inner modify the global $i already; the collision did not show, because the master loop for i in {1..3}; do resets $i to the next value from list "1 2 3" rather than adding 1 to $i's current value. So right after inner returns, $i is "corrupted" for a brief moment in the original code, already. If we were to fix the C-style loop version, falling back to plain for loops is addressing symptoms more than causes. For addressing causes, I would like to propose both a soft and a hard fix:

#! /bin/bash
inner() {
    local i  # soft fix
    for ((i = 1; i <= 4; i++)); do
        echo "  $i"
    done
}

outer() {
    local i
    for ((i = 1; i <= 3; i++)); do
        echo "$i"
        ( inner )  # hard fix
    done
}

outer

The soft fix is declaring $i as local (to inner) so that it does not modify the global $i ("shadowing"). (The new local i in outer is for hygiene, and not taking part in this particular fix.) The hard fix is calling inner from a subshell so that outer does not have to trust inner on globals. To summarize:

  • Loop variables are global by default, too: Better turn them local.
  • Declare as many variables local as possible, in general.