Using Arrays in Bash Instead of String Hacks
AI generated
Bash · Shell Scripting · Arrays · Linux
Using Arrays in Bash the Practical Way
instead of string hacks and whitespace traps

String hacks in Bash, file lists as whitespace-separated strings, configuration values as comma-separated fields, break systematically as soon as a value contains a special character. Arrays in Bash are the clean alternative: indexed arrays for ordered lists, associative arrays for key-value structures, slices for subsets and safe iteration for every use case.

13 min read indexed · associative · iteration · slices · manipulation Bash 4.x · 5.x · Linux

1. The string hack problem in practice

The classic string hack in Bash scripts looks harmless: FILES=$(find /var/log -name "*.log") stores results in a variable, and for f in $FILES iterates over it. That works reliably right up until a filename contains a space, a tab, a newline or a glob character. Once one of those cases occurs, and in production environments it eventually does, the script breaks in unpredictable ways. Worse still, it often does not fail with an error but with silent, wrong behavior.

The second common string hack is storing configuration values as comma-separated or space-separated strings: SERVICES="nginx mysql redis". That looks simple until a service name contains a hyphen or a version number and the splitting suddenly becomes ambiguous. Arrays in Bash solve this problem fundamentally: each element is stored separately, with no delimiter logic, and passed correctly as long as you write "${array[@]}" with double quotes.

The mental model for moving from string hacks to Bash arrays: a variable is a single value, an array is an ordered list of values. Whenever the problem description uses the word "list" or "multiple", a Bash array is the correct data structure. That applies equally to file lists, service names, IP addresses, validation rules, command line arguments and configuration keys.

2. Indexed arrays: basics and declaration

Indexed arrays in Bash are zero-based lists of strings. Declaring them with declare -a name is optional but explicit, without declare -a Bash will also create an array on the first assignment attempt. The syntax array=(elem1 elem2 elem3) initializes an array with literal values. Individual elements are set with array[0]="value" and read with ${array[0]}. Access to all elements happens via ${array[@]}, and the element count via ${#array[@]}. These four basic operations cover most use cases.

An important difference between Bash arrays and arrays in other programming languages: Bash arrays do not need to be densely populated. It is perfectly legitimate to occupy index 0, 5 and 100 without filling the gaps in between. That leads to unexpected results when iterating over numeric indices under the assumption that they run seamlessly from 0 to n-1. The safest pattern is always element iteration with "${array[@]}" instead of an index loop from 0 to ${#array[@]}, because the latter targets the wrong indices when there are gaps.


#!/usr/bin/env bash
# arrays_basics.sh - indexed array fundamentals
set -euo pipefail

# Declaration and initialization
declare -a services=("nginx" "mysql" "redis" "php-fpm")

# Read single element
echo "First service: ${services[0]}"

# Number of elements - no subshell needed
echo "Total services: ${#services[@]}"

# Last element (Bash 4.2+)
echo "Last service: ${services[-1]}"

# All indices (not values) - useful for sparse arrays
echo "Indices: ${!services[@]}"

# Append to array
services+=("memcached")
echo "After append: ${#services[@]} services"

# Slice: elements 1 and 2 (offset=1, length=2)
slice=("${services[@]:1:2}")
echo "Slice [1:2]: ${slice[*]}"

# Unset a single element - array becomes sparse
unset 'services[2]'
echo "After unset index 2: ${#services[@]} elements"

# Safe iteration - always double-quote [@]
for svc in "${services[@]}"; do
  echo "  -> $svc"
done

3. Filling arrays: literals, loops and find

The safest pattern for filling a Bash array with file paths is combining find -print0 with a while IFS= read -r -d '' f loop. -print0 separates filenames with the null byte instead of newlines, the only character that can never appear in a file path on Linux. read -d '' reads up to the next null byte, -r prevents backslash interpretation, and IFS= prevents whitespace trimming. Every filename ends up as its own element in the array, correctly, regardless of special characters.

Another important way to fill arrays in Bash: mapfile (synonym: readarray), available since Bash 4. mapfile -t array < <(command) reads each line of a command's output as one array element and strips the trailing newline via -t. That is more compact than the while loop, but has the same drawback: newlines inside a filename are interpreted as separators. For file lists with potentially problematic names, the find -print0 method remains the safest choice. For regular command output such as git branch --list or lines from a configuration file, mapfile -t is the most compact solution.

4. Correct iteration: [@] vs [*] and quoting rules

The most critical detail when working with Bash arrays is the difference between "${array[@]}" and "${array[*]}". With double quotes, @ expands the array into separate, individually quoted elements, each element is its own argument, and spaces inside an element value are preserved correctly. With *, on the other hand, all elements are merged into a single string, separated by the first character of IFS. The difference is only visible with elements that contain spaces, but then it is fatal: for f in "${array[*]}" iterates exactly once over all elements as a single string instead of n times over n elements.

Without quotes the behavior is even more problematic: for f in ${array[@]} is subject to word splitting and glob expansion. An element "file name.txt" becomes two loop iterations, and an element "*.log" is glob-expanded into a list of files in the current directory. That is the core of the string hack problem, and the reason why "${array[@]}" with double quotes is the only correct pattern for iterating over arrays in Bash.


#!/usr/bin/env bash
# iteration.sh - safe array population and iteration
set -euo pipefail

# CORRECT: null-delimited find - handles all special characters
declare -a log_files=()
while IFS= read -r -d '' f; do
  log_files+=("$f")
done < <(find /var/log -maxdepth 2 -name "*.log" -print0 2>/dev/null)

echo "Found ${#log_files[@]} log files"

# CORRECT: mapfile for normal line-based output (no filenames with newlines)
declare -a branches=()
mapfile -t branches < <(git branch --list 2>/dev/null | sed 's/^[* ] //')
echo "Git branches: ${#branches[@]}"

# CORRECT: iterate with double-quoted [@] - always
for f in "${log_files[@]}"; do
  size=$(stat --format="%s" "$f" 2>/dev/null || echo 0)
  echo "  $f ($size bytes)"
done

# WRONG (for illustration - do NOT use):
# for f in ${log_files[*]}  - splits on IFS, glob-expands
# for f in "${log_files[*]}" - one giant string instead of N elements

# Passing array to command - each element as separate argument
if [[ ${#log_files[@]} -gt 0 ]]; then
  ls -lh "${log_files[@]}"
fi

5. Array slices, length, last element and subarrays

The slice syntax for Bash arrays follows the same principle as substring expansion for strings: ${array[@]:offset:length} returns length elements starting at position offset. The result is a list of elements that you can assign to a new array: sub=("${array[@]:2:5}"). If you omit length, you get all elements from offset to the end. The pattern ${array[@]: -1} (the space before the minus is required so Bash does not interpret it as parameter expansion for a default value) returns the last element on Bash versions before 4.2. From Bash 4.2 onward, ${array[-1]} also works directly.

Batch processing with array slices is a powerful pattern for resource-intensive operations: instead of processing all elements at once, you take a fixed count each time (batch_size=10) and iterate with a for ((i=0; i<${#arr[@]}; i+=batch_size)) loop. In each iteration you extract the current batch as a subarray and process it. This pattern protects against resource exhaustion on very large lists and enables progress reporting between batches.

6. Manipulating arrays: appending, removing, sorting

Appending to a Bash array is a single operation with array+=(new_element). Removing an element is less direct: unset 'array[index]' removes the element but leaves a gap in the array. Anyone who needs a dense array without gaps has to rebuild the array after removal: array=("${array[@]/element_to_remove/}") removes all occurrences of a value via empty substitution, but leaves empty elements behind. A cleaner approach is a filter pattern with a loop that copies every element except the one to remove into a new array.

Sorting Bash arrays requires a detour through external tools, since Bash has no built-in sort function for arrays. The standard pattern: mapfile -t sorted < <(printf '%s\n' "${array[@]}" | sort) pipes all elements to sort and reads the result back line by line into a new array. For numeric sorting: sort -n. For reverse sorting: sort -r. For deduplication: sort -u. The pattern is simple and efficient, and you never have to implement your own sorting algorithm in Bash.


#!/usr/bin/env bash
# manipulation.sh - array manipulation patterns
set -euo pipefail

declare -a servers=("web01" "web02" "db01" "cache01" "web03")

# Append
servers+=("monitor01")
echo "After append: ${servers[*]}"

# Filter: remove all "web*" entries (rebuild without matching elements)
declare -a non_web=()
for s in "${servers[@]}"; do
  [[ "$s" != web* ]] && non_web+=("$s")
done
echo "Non-web: ${non_web[*]}"

# Deduplicate while preserving order
declare -A seen=()
declare -a unique=()
for s in "${servers[@]}"; do
  [[ -v seen["$s"] ]] && continue
  seen["$s"]=1
  unique+=("$s")
done
echo "Unique count: ${#unique[@]}"

# Sort array
declare -a sorted=()
mapfile -t sorted < <(printf '%s\n' "${servers[@]}" | sort)
echo "Sorted: ${sorted[*]}"

# Reverse sort numerically
declare -a numbers=(42 7 19 3 100 55)
declare -a num_sorted=()
mapfile -t num_sorted < <(printf '%d\n' "${numbers[@]}" | sort -rn)
echo "Numeric reverse: ${num_sorted[*]}"

# Find index of an element
target="db01"
for i in "${!servers[@]}"; do
  [[ "${servers[$i]}" == "$target" ]] && echo "Found '$target' at index $i"
done

7. Associative arrays: key-value without external tools

Associative arrays in Bash (since Bash 4, declared with declare -A) are key-value structures where the keys can be arbitrary strings. They replace a whole class of shell scripts that previously relied on external tools such as awk, sed or even temporary files to manage key-value pairs. The syntax declare -A config=([host]="localhost" [port]="3306") initializes an associative array. Access via ${config[host]}, all keys via ${!config[@]}, all values via ${config[@]}.

A practical use case for associative Bash arrays: frequency counters. Instead of reaching for external tools, you can count occurrences directly in an associative array: ((counter["$key"]++)). That works for log analysis, statistics on deployment results, or frequency distributions of configuration values. A second use case: lookup tables for fast membership testing. [[ -v seen["$element"] ]] checks whether a key exists in O(1) instead of a linear search through an indexed array.


#!/usr/bin/env bash
# assoc_arrays.sh - associative array patterns
set -euo pipefail

# Database configuration as associative array
declare -A db=(
  [host]="db.internal"
  [port]="3306"
  [name]="magento"
  [user]="app_user"
)

echo "Connecting to ${db[host]}:${db[port]}/${db[name]}"

# Check if key exists
if [[ -v db[password] ]]; then
  echo "Password configured"
else
  echo "[WARN] No database password set - check env variables"
fi

# Iterate over all key-value pairs
echo "--- Database config ---"
for key in "${!db[@]}"; do
  printf "  %-12s = %s\n" "$key" "${db[$key]}"
done

# Frequency counter - count log levels in a file
declare -A level_count=()
while IFS= read -r line; do
  for level in ERROR WARNING INFO DEBUG; do
    if [[ "$line" == *"[$level]"* ]]; then
      ((level_count["$level"]++)) || true
      break
    fi
  done
done < /var/log/app.log 2>/dev/null || true

echo "--- Log level frequencies ---"
for level in ERROR WARNING INFO DEBUG; do
  printf "  %-10s %d\n" "$level" "${level_count[$level]:-0}"
done

# Lookup table for environment-specific config
declare -A deploy_host=(
  [dev]="dev.mironsoft.local"
  [staging]="staging.mironsoft.de"
  [prod]="mironsoft.de"
)
ENV="${DEPLOY_ENV:-dev}"
echo "Target host: ${deploy_host[$ENV]:-unknown}"

8. Passing and returning arrays with functions

Passing arrays in Bash to functions is one of the topics that many guides explain incorrectly. Bash arrays cannot be passed directly as parameters the way they are in other programming languages, myfunc "${my_array}" only passes the first element. The correct pattern is myfunc "${my_array[@]}", where the function receives the elements as "$@" and copies them internally into a local array: local -a items=("$@"). That works as long as the function accepts exactly one array.

For returning arrays from Bash functions there are two patterns. First: the function outputs values via echo and the caller captures them with mapfile -t result < <(myfunc), simple, but it spawns a subshell. Second: name references (local -n outarray="$1", Bash 4.3+), the function receives the variable name as its first parameter and writes directly into the caller's array. The second pattern is more efficient but requires Bash 4.3 and careful naming (no name collision between the name reference and a local parameter).

9. String hacks vs. arrays head to head

The comparison shows clearly why arrays in Bash outperform string hacks in nearly every use case.

Task String hack (unsafe) Array (correct) Benefit
File list FILES=$(find …) find -print0 | read -d'' Safe with special characters and spaces
Service list SVCS="nginx mysql" declare -a svcs=(…) Clean iteration, no delimiter logic
Key-value CONFIG="host=db:port=3306" declare -A cfg=([host]=db) Direct access by key, no parsing logic
Deduplication echo "$str" | sort -u declare -A seen; [[ -v seen[$k] ]] O(1) lookup, no child process
Batch processing Barely possible without a tmpfile ${array[@]:i:batch_size} Slice syntax, no helper program needed

In practice, the most common reason developers still reach for string hacks is habit and the supposedly more compact syntax. In reality, the line mapfile -t arr < <(command) is no longer than VAR=$(command), but it is correct. With set -euo pipefail and ShellCheck, string hack patterns get flagged as warnings, which supports the move to Bash arrays.

Mironsoft

Shell automation, DevOps tooling and deployment infrastructure

Want to replace fragile string hacks in your Bash scripts?

We audit existing shell scripts for string hack patterns, replace them with robust array-based solutions and integrate ShellCheck into your pipeline, so special characters never stop a deployment again.

Array refactoring

Replace string hacks with indexed and associative arrays

ShellCheck integration

Add static analysis to CI and fix existing warnings

Code review

Manual review for hidden quoting and array bugs

10. Summary

Using arrays in Bash instead of string hacks means choosing the data structure that fits the problem. Indexed arrays for ordered lists of elements, associative arrays for key-value structures, array slices for subsets. The key pattern is always "${array[@]}" with double quotes, that is the only difference between a script that works with special characters and one that breaks. find -print0 with read -r -d '' is the standard pattern for file lists. mapfile -t for line-based output.

Associative arrays, available since Bash 4, are often the most elegant solution for problems that used to require awk, temporary files or complex string parsing logic. The most common use cases: lookup tables for fast O(1) membership tests, frequency counters and configuration objects with named fields. With ShellCheck in the CI pipeline, string hack patterns get flagged automatically as warnings, which systematically supports a gradual migration to clean array patterns in Bash.

Using arrays in Bash: the essentials at a glance

Indexed arrays

find -print0 | read -r -d '' fills safely. Always iterate with "${array[@]}" using double quotes. ${array[@]:offset:len} for slices.

Associative arrays

declare -A (Bash 4+). Keys with ${!arr[@]}, values with ${arr[@]}. [[ -v arr[key] ]] for O(1) existence checks.

[@] vs [*]

@ with double quotes: separate quoted elements. * merges everything into one string. For iteration: always "${array[@]}".

mapfile

mapfile -t arr < <(command), compact and correct for line-based output. No manual while-read needed. -t strips the trailing newline.

11. FAQ: Using Arrays in Bash the Practical Way

1${array[@]} vs. ${array[*]}?
@ with double quotes: separate quoted elements, spaces inside an element are preserved. * merges everything into one IFS-separated string. For iteration always "${array[@]}".
2mapfile does not work in a pipe?
Pipes run in subshells, variable changes are not visible outside. Use process substitution instead: mapfile -t arr < <(cmd). That runs mapfile in the current shell context.
3Checking for an empty array?
[[ ${#array[@]} -eq 0 ]] for the count. [[ -z ${array+x} ]] checks whether the array variable is declared at all. Under set -u, the second form is safer when the array is not guaranteed to be declared.
4Are arrays available in POSIX sh?
No, arrays are not POSIX. Only Bash, ksh, zsh and similar extended shells. For Bash-specific scripts (#!/usr/bin/env bash) unreservedly recommended.
5Passing an array to a function?
myfunc "${array[@]}" passes all elements separately. Inside the function: local -a local_arr=("$@"). For returning an array: name references with local -n ref=$1 (Bash 4.3+).
6Associative instead of indexed, when?
For named access: configuration fields, lookup tables, counters. Indexed for ordered sequences and batch processing. Associative arrays require Bash 4 (declare -A).
7Removing duplicates from an array?
Associative seen array: for e in "${arr[@]}"; do [[ -v seen[$e] ]] && continue; seen[$e]=1; unique+=($e); done. Or: mapfile -t arr < <(printf '%s\n' "${arr[@]}" | sort -u), sorts as a side effect.
8Reading a config file into an associative array?
while IFS='=' read -r key value; do config["$key"]="$value"; done < config.ini. Skip comment lines with [[ "$key" == '#'* ]] && continue.
9Is find -print0 safer than without it?
-print0 separates entries with the null byte, the only character that never appears in a file path. Newlines can legally occur in filenames. read -d '' reads up to the null byte.
10Sorting a Bash array numerically?
mapfile -t sorted < <(printf '%d\n' "${array[@]}" | sort -n). Reverse: sort -rn. Bash has no built-in sort function, so sort is the standard tool.