Skip to main content
Bash · lesson 9 of 22
Bash intermediate Lesson 9 of 22

Bash Arrays

Work with Bash indexed and associative arrays: slicing, quoting with [@] vs [*], mapfile, filtering, sorting, and passing arrays to functions.

By SkillByExample · Updated

The data-types lesson introduced arrays. This one covers the everyday operations: loops, reading files, filtering, sorting, and passing arrays around. Every example is a complete script, run with bash 5.2 on Ubuntu 24.04.

Growing, Slicing, Removing

fruits=("apple" "banana" "cherry")
fruits+=("date" "elderberry" "fig")
echo "count=${#fruits[@]} last=${fruits[-1]}"
echo "slice 1:3 -> ${fruits[*]:1:3}"
echo "last two  -> ${fruits[*]: -2}"

unset 'fruits[1]'
echo "indices after unset: ${!fruits[*]}"
fruits=("${fruits[@]}")
echo "indices after re-pack: ${!fruits[*]}"
$ bash basics.sh
count=6 last=fig
slice 1:3 -> banana cherry date
last two  -> elderberry fig
indices after unset: 0 2 3 4 5
indices after re-pack: 0 1 2 3 4

+=( ... ) appends any number of elements. unset leaves a hole in the indices, and re-assigning the array from "${fruits[@]}" packs them together again. That is also how you copy an indexed array.

[@] vs [*], Quoted and Not

How you expand an array decides how many words the command receives.

files=("report.txt" "my notes.txt" "todo.txt")

echo "unquoted \${files[@]}:"
for f in ${files[@]}; do echo "  [$f]"; done

echo "quoted \"\${files[@]}\":"
for f in "${files[@]}"; do echo "  [$f]"; done

echo "quoted \"\${files[*]}\":"
for f in "${files[*]}"; do echo "  [$f]"; done
$ bash quoting.sh
unquoted ${files[@]}:
  [report.txt]
  [my]
  [notes.txt]
  [todo.txt]
quoted "${files[@]}":
  [report.txt]
  [my notes.txt]
  [todo.txt]
quoted "${files[*]}":
  [report.txt my notes.txt todo.txt]

Only "${files[@]}" gives one word per element. Unquoted, my notes.txt splits in two. "${files[*]}" joins everything into one string, which is useful for display and nothing else. When you need the positions as well as the values, loop over "${!arr[@]}":

servers=("web01" "web02" "db01")
for i in "${!servers[@]}"; do
  echo "$i: ${servers[$i]}"
done
$ bash index-loop.sh
0: web01
1: web02
2: db01

Reading Lines with mapfile

mapfile (also called readarray) reads input into an array, one element per line.

printf 'alpha\nbeta gamma\n\ndelta\n' > hosts.txt

mapfile -t lines < hosts.txt
echo "with -t: ${#lines[@]} lines"
printf '  [%s]\n' "${lines[@]}"

mapfile no_t < hosts.txt
[[ ${no_t[0]} == "alpha" ]] && echo "without -t: equal" || echo "without -t: 'alpha' != '${no_t[0]%$'\n'}\\n'"
$ bash mapfile.sh
with -t: 4 lines
  [alpha]
  [beta gamma]
  []
  [delta]
without -t: 'alpha' != 'alpha\n'

beta gamma stayed a single element and the blank line was kept. Without -t, every element keeps its trailing newline, so an ordinary string comparison quietly fails. Always pass -t. For command output, use mapfile -t arr < <(command).

Filter, Join, Sort

servers=("web01" "web02" "db01" "db02" "cache01")

db=()
for s in "${servers[@]}"; do
  [[ $s == db* ]] && db+=("$s")
done
echo "filtered: ${db[*]}"

join_by() { local d="$1" first="$2"; shift 2; printf '%s' "$first" "${@/#/$d}"; echo; }
join_by ', ' "${servers[@]}"

nums=(10 2 30 4 20)
mapfile -t text_sorted < <(printf '%s\n' "${nums[@]}" | sort)
mapfile -t num_sorted  < <(printf '%s\n' "${nums[@]}" | sort -n)
echo "sort:    ${text_sorted[*]}"
echo "sort -n: ${num_sorted[*]}"
$ bash ops.sh
filtered: db01 db02
web01, web02, db01, db02, cache01
sort:    10 2 20 30 4
sort -n: 2 4 10 20 30

join_by uses ${@/#/$d} to prefix every remaining argument with the delimiter. Bash has no array sort, so you print one element per line, pipe the lines through sort, and read them back with mapfile. Plain sort compares text, which puts 10 before 2. Numbers need sort -n.

Returning a List: Where arr=($(...)) Breaks

A function can return a list by printing one item per line. How you capture that output matters:

dedup() {
  local -A seen=()
  local item
  for item in "$@"; do
    [[ -v seen[$item] ]] || { seen[$item]=1; printf '%s\n' "$item"; }
  done
}

items=("a" "new york" "a" "b" "new york")

broken=($(dedup "${items[@]}"))
echo "word-split capture: ${#broken[@]} elements"
printf '  [%s]\n' "${broken[@]}"

mapfile -t fixed < <(dedup "${items[@]}")
echo "mapfile capture: ${#fixed[@]} elements"
printf '  [%s]\n' "${fixed[@]}"
$ bash dedup.sh
word-split capture: 4 elements
  [a]
  [new]
  [york]
  [b]
mapfile capture: 3 elements
  [a]
  [new york]
  [b]

The deduplication worked in both cases. The arr=($(...)) capture then split new york in two, because unquoted command substitution splits on every space and also expands globs. The associative array seen works as a set, and mapfile -t keeps each line as exactly one element.

Passing Arrays to Functions

"${arr[@]}" passes the elements, not the array. To let a function read the array, or change it, pass its name and use a nameref.

print_array() {
  local -n arr="$1"
  echo "$1 has ${#arr[@]} elements:"
  for i in "${!arr[@]}"; do echo "  [$i] = ${arr[$i]}"; done
}

add_item() {
  local -n target="$1"
  target+=("$2")
}

fruits=("apple" "banana")
add_item fruits "cherry"
print_array fruits
$ bash pass.sh
fruits has 3 elements:
  [0] = apple
  [1] = banana
  [2] = cherry

add_item changed the caller’s array directly, with no subshell and nothing to capture. Give the nameref a name the caller is unlikely to use. A nameref called arr that is handed a variable also called arr is a circular reference.

Practice

  1. Reverse arr=(one two three four) into a new array.

    Solution
    arr=(one two three four)
    rev=()
    for (( i = ${#arr[@]} - 1; i >= 0; i-- )); do
      rev+=("${arr[i]}")
    done
    echo "${rev[*]}"
    $ bash p1.sh
    four three two one
  2. Process hosts=(h1 h2 h3 h4 h5) in batches of 2 using array slicing.

    Solution
    hosts=(h1 h2 h3 h4 h5)
    size=2
    for (( i = 0; i < ${#hosts[@]}; i += size )); do
      echo "batch: ${hosts[*]:i:size}"
    done
    $ bash p2.sh
    batch: h1 h2
    batch: h3 h4
    batch: h5

    A slice that runs past the end just returns whatever is left, so the last batch is shorter.

  3. Remove every banana from ("apple" "banana" "cherry" "banana") and leave no gaps in the indices.

    Solution
    fruits=("apple" "banana" "cherry" "banana")
    kept=()
    for f in "${fruits[@]}"; do
      [[ $f == banana ]] || kept+=("$f")
    done
    fruits=("${kept[@]}")
    echo "${#fruits[@]}: ${fruits[*]}"
    $ bash p3.sh
    2: apple cherry
  4. Copy an associative array, then change the copy. Show that the original is untouched.

    Solution
    declare -A src=([host]=db [port]=5432)
    declare -A copy=()
    for k in "${!src[@]}"; do copy[$k]=${src[$k]}; done
    copy[port]=6543
    echo "src port=${src[port]} copy port=${copy[port]} copy host=${copy[host]}"
    $ bash p4.sh
    src port=5432 copy port=6543 copy host=db

Frequently Asked Questions

Why does my array lose its values inside a while loop?
Piping to a while loop runs it in a subshell, so variable changes don't propagate back. Use process substitution: while ... done < <(command) instead of command | while.
How do I copy an array in Bash?
Use new_array=("${old_array[@]}"). For associative arrays you need a loop, as there is no built-in copy syntax.
Can I have nested arrays in Bash?
No. Bash does not support nested arrays. Workarounds include encoding data as strings with delimiters, using nameref tricks, or switching to Python for complex data structures.
How do I read command output into a Bash array safely?
Use mapfile -t arr < <(command). It stores one element per line and keeps spaces inside lines intact. The tempting arr=($(command)) splits on every space and also expands glob characters, so any line containing a space becomes several elements.