A sysadmin stored a fleet's server list the obvious way: a space-separated string, SERVERS="web-01 web-02 db-prod", iterated with for host in $SERVERS. It worked for two years. Then the cloud provider assigned a new instance the display name web-prod 01 — with a space, which their naming policy permitted — and the next maintenance run looped over web-prod and 01 as two separate hosts. The script tried to SSH into both, retried on failure, and generated 47 connection attempts to nonexistent hostnames in under a minute. AWS flagged it as a possible compromised host and locked the account pending review. The root cause was not the space in the hostname. It was storing a list as a string. An array would have made that space completely invisible, because each element of an array stays one element no matter what characters it contains.
Declaring indexed arrays
Two syntaxes, both producing a real array:
The middle elements contain spaces, and that is fine — dragon fruit and db-prod 02 are each a single element. The same characters in a space-separated string would have been two items.
The critical distinction: [@] vs [*]
This is the difference that prevents the outage above:
"${servers[@]}" in double quotes expands to one word per element. "${servers[*]}" instead joins every element into a single string separated by the first character of IFS (a space by default). For iterating or passing arguments to a command, you want [@] essentially always. Reserve [*] for the rare case where you want one display string — for example echo "Checking: ${servers[*]}". Drop the quotes on either form and you are back to uncontrolled word-splitting, so keep the double quotes.
Appending
Grow an array with the += compound assignment:
Each += adds exactly one element regardless of spaces inside it. The tempting shortcut list="$list new item" flattens everything back into a string and reintroduces the exact bug arrays exist to eliminate. Always append with +=( ).
Length and validation
${#array[@]} is the element count — and it is genuinely useful for guarding input:
Watch the trap here: ${#array[@]} counts elements, but ${#array} (no [@]) gives the string length of the first element. They look almost identical and mean entirely different things. To count, always include [@].
Slicing
Extract a sub-range with ${array[@]:start:length}:
Useful for paging through a long list or skipping a known header element.
Associative arrays
Bash 4+ supports key/value maps via declare -A. The canonical use is a lookup table — service names to the ports they should be listening on:
"${!service_ports[@]}" (note the !) expands to the keys; "${service_ports[$key]}" fetches a value. This replaces brittle parallel arrays or case statements with a direct, readable map. Associative arrays require bash 4.0+, so they will not work on the system bash that ships on macOS (3.2) — relevant if your script runs on mixed CI runners.
Iterating by index, deleting, and passing to functions
Sometimes you need the index, not just the value — to modify an element in place or report its position. "${!array[@]}" expands to the list of indices:
Deleting with unset leaves a gap rather than renumbering. unset 'releases[1]' removes index 1, but indices 0 and 2 remain, so the array is now sparse and a later ${releases[1]} is empty. To compact it back to contiguous indices, reassign through [@]:
Passing an array into a function needs care, because "$@" flattens every argument into one flat list and the receiving function cannot tell where one array ended. Pass it by name with a nameref instead:
The nameref (local -n, bash 4.3+) hands the whole array to the function without copying it through positional parameters, so elements with spaces survive and you avoid the classic bug of an array silently collapsing into a string at the function boundary.
Complete production script
A service health checker that demonstrates the whole point of arrays: it stores the service list as an array, checks each one, collects every failure into a second array, and reports all of them at the end — rather than stopping at the first failure and hiding the rest.
The associative array maps each service to its port with no parallel-array bookkeeping. The indexed failures array accumulates problems so a single dead service does not mask three others — ${failures[*]} prints them as one readable line and ${#failures[@]} drives the exit code. Stopping at the first failure is how you spend an hour fixing nginx and then discover postgres was also down the whole time; collecting failures into an array is how you see the full picture in one run.