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.
Table of Contents
- 1. The string hack problem in practice
- 2. Indexed arrays: basics and declaration
- 3. Filling arrays: literals, loops and find
- 4. Correct iteration: [@] vs [*] and quoting rules
- 5. Array slices, length, last element and subarrays
- 6. Manipulating arrays: appending, removing, sorting
- 7. Associative arrays: key-value without external tools
- 8. Passing and returning arrays with functions
- 9. String hacks vs. arrays head to head
- 10. Summary
- 11. FAQ
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.