In the previous lesson we slipped in a line without explaining it: readonly CITIES=(Valencia Sevilla Bilbao Madrid), later walked with for city in "${CITIES[@]}". That is an array, and it is the data structure daily-report.sh was missing to stop reading the CSV once per city. Bash has two kinds: indexed arrays, ordered lists with a numeric index, and associative arrays, maps from an arbitrary key to a value, which are the native answer to "how many issues does each city have" without leaving the shell. By the end of this lesson you will know how to accumulate counters in a single pass and retire the sort | uniq -c from Module 2 whenever the result has to be used inside the script.

Contents

  1. Creating indexed arrays
  2. Accessing elements and the whole array
  3. "${arr[@]}" versus "${arr[*]}"
  4. Length, indexes and iteration
  5. Appending, slicing and deleting
  6. Associative arrays
  7. The counter pattern
  8. Populating arrays: mapfile and IFS
  9. Passing arrays to functions
  10. Operations table
  11. Applying it in daily-report.sh

  1. Creating indexed arrays

There are three ways, and they all produce the same thing:

cities=(Valencia Sevilla Bilbao Madrid)            # literal, the usual one
declare -a couriers                                # explicit declaration, empty
couriers[0]="alopez"; couriers[1]="mgarcia"
couriers[5]="jruiz"                                # indexes may jump

Key points: elements are separated by spaces, not commas ((a, b) creates the elements a, and b); if an element contains spaces it must be quoted, cities=("San Sebastián" Valencia); indexes start at 0 and are sparse, so the array above has 3 elements with indexes 0, 1 and 5 and there are no empty holes in between; and declare -a is not mandatory, but it documents the intent and is needed if you want the array empty from the start. A normal variable is, in fact, element 0 of an array: x=hello; echo "${x[0]}" prints hello.

  1. Accessing elements and the whole array

cities=(Valencia Sevilla Bilbao Madrid)
echo "${cities[0]}"          # Valencia
echo "${cities[-1]}"         # Madrid    (negative index: from the end, Bash 4.3+)
echo "$cities"               # Valencia  ← trap! with no index it means [0]

The braces are mandatory. $cities[1] does not access element 1: Bash expands $cities (element 0) and leaves [1] as literal text. Always ${cities[1]}.

The index can be any arithmetic expression: ${cities[i+1]} works, and inside the brackets variables do not need $.

  1. "${arr[@]}" versus "${arr[*]}"

It is exactly the same distinction as "$@" versus "$*" from 03-05, and for the same reason:

Expansion Result
"${arr[@]}" One argument per element, with internal spaces intact
"${arr[*]}" A single argument, elements joined by the first character of IFS
${arr[@]} unquoted The spaces inside each element get split too
books=("Don Quixote" "Moby Dick")
for x in "${books[@]}"; do echo "[$x]"; done   # [Don Quixote] [Moby Dick] → 2 rounds ✓
for x in "${books[*]}"; do echo "[$x]"; done   # [Don Quixote Moby Dick]   → 1 round
for x in ${books[@]};   do echo "[$x]"; done   # [Don][Quixote][Moby][Dick] → 4, disaster

Rule with no exceptions: to iterate or pass arrays, "${arr[@]}" with quotes. "${arr[*]}" is only good for one useful thing, joining the elements into a readable string using IFS: IFS=', '; echo "Cities: ${cities[*]}"; unset IFS prints Cities: Valencia, Sevilla, Bilbao, Madrid.

  1. Length, indexes and iteration

echo "${#cities[@]}"        # 4        → number of elements
echo "${#cities[2]}"        # 6        → length of the STRING in element 2 ("Bilbao")
echo "${!cities[@]}"        # 0 1 2 3  → list of existing indexes

Watch the asymmetry: ${#arr[@]} counts elements, but ${#arr[i]} counts characters. And ${!arr[@]} with a leading ! returns the keys, not the values; it is the only safe way to walk a sparse array:

couriers[0]="alopez"; couriers[5]="jruiz"
for i in "${!couriers[@]}"; do printf 'index %d → %s\n' "$i" "${couriers[$i]}"; done
# index 0 → alopez
# index 5 → jruiz

If you had written for (( i=0; i<${#couriers[@]}; i++ )) you would walk indexes 0 and 1, and element 5 would never show up. That C-style for is only reliable on arrays with no holes.

  1. Appending, slicing and deleting

cities+=(Zaragoza)                    # appends at the end; do NOT use cities=(... Zaragoza)
cities+=(Málaga Vigo)                 # several can be added at once
echo "${cities[@]:1:2}"               # Sevilla Bilbao → slice: from 1, two elements
echo "${cities[@]: -2}"               # the last two (mind the space before the -2)

Deletion has a quirk that surprises everybody:

cities=(Valencia Sevilla Bilbao Madrid)
unset 'cities[1]'
echo "${#cities[@]}"       # 3
echo "${!cities[@]}"       # 0 2 3   ← index 1 is gone, it has NOT been reindexed
cities=("${cities[@]}")       # this is how you reindex to 0,1,2
unset 'cities'                # this is how you delete the whole array

unset leaves a hole: the array becomes sparse, and only rebuilding it makes it compact again. The quotes in unset 'cities[1]' stop globbing from reading the brackets as a character class if a file with that name exists.

  1. Associative arrays

Available since Bash 4 (2009). Remember from 01-02 how to check your version, because on macOS the system's /bin/bash is still 3.2: (( BASH_VERSINFO[0] >= 4 )) || { echo "Bash 4+ required" >&2; exit 5; }.

Unlike indexed ones, declare -A is mandatory: without it, Bash treats keys as arithmetic expressions and everything ends up at index 0.

declare -A region                    # MANDATORY
region["Valencia"]="Levante"; region["Sevilla"]="South"
region[Bilbao]="North"               # quotes only if the key contains spaces
echo "${region[Valencia]}"           # Levante
echo "${#region[@]}"                 # 3
echo "${!region[@]}"                 # Sevilla Valencia Bilbao ← ORDER NOT GUARANTEED

They can also be created in one go: declare -A region=([Valencia]=Levante [Sevilla]=South).

The key order is that of the internal hash table, neither insertion order nor alphabetical. If you need sorted output, sort it yourself:

for c in $(printf '%s\n' "${!region[@]}" | sort); do printf '%-12s %s\n' "$c" "${region[$c]}"; done

To check whether a key exists, without confusing it with one that exists but holds an empty string, you use if [[ -v region[Madrid] ]]; then .... [[ -v ]] (Bash 4.2+) asks about the existence of the variable or element. The old alternative, [[ -n "${region[Madrid]}" ]], only looks at whether the value is non-empty, which is not the same thing. To delete a key: unset 'region[Madrid]'.

  1. The counter pattern

This is the main reason associative arrays exist in an operations script. The Module 2 form —grep ',issue,' "$CSV_PATH" | cut -d, -f3 | sort | uniq -c— is elegant in the terminal but useless inside the script: the result is text, and to use Sevilla's number you would have to parse it again. With a map, the data stays in variables:

declare -A issues_by_city shipments_by_city

while IFS=, read -r _ _ city _ status _; do
    (( shipments_by_city["$city"]++ ))
    [[ "$status" == "issue" ]] && (( issues_by_city["$city"]++ ))
done < <(tail -n +2 "$CSV_PATH")

for city in "${!shipments_by_city[@]}"; do
    printf '%-10s %4d shipments, %3d issues\n' \
        "$city" "${shipments_by_city[$city]}" "${issues_by_city[$city]:-0}"
done

Output: Valencia 128 shipments, 11 issues and one identical line per city. Three details that make this work:

  • (( map["$key"]++ )) works even if the key does not exist yet: in arithmetic context an unset variable counts as 0, so the first increment creates it with value 1. Nothing needs initializing.
  • ${issues_by_city[$city]:-0} uses the default value from 03-06 for cities with no issues, which have no entry in that map.
  • The whole count is done in a single pass and with no external processes: it replaces eight grep invocations. For files of millions of lines, though, the Bash loop is slow and it is better to delegate to awk (06-01).

  1. Populating arrays: mapfile and IFS

To fill an array with the output of a command, one line per element, the right tool is mapfile (also called readarray, they are synonyms):

mapfile -t logs < <(find /var/log/veloz -name 'app.log.*' -type f)
echo "${#logs[@]} rotated files"; printf '  %s\n' "${logs[@]}"

The -t option removes the trailing newline of each element; without it every entry would carry a \n and comparisons would fail. Other useful options: -n N (read at most N lines) and -s N (skip the first N, handy for a CSV header). Never use arr=( $(command) ) unquoted for this: it splits on spaces and applies globbing, so a name with spaces or a * in the output breaks the array.

To split a string into an array, you change IFS temporarily and use read -a:

line="E1001,2026-08-03,Valencia,alopez,delivered,24.50"
IFS=',' read -r -a fields <<< "$line"
echo "${fields[2]}"      # Valencia
echo "${#fields[@]}"     # 6

<<< is a here-string: it feeds the command's standard input with that string (you will see it in depth in 05-05). -a fields tells read to distribute the fields into the array instead of into separate variables. Since the IFS= goes before the command, it only affects that line.

  1. Passing arrays to functions

Here is a real Bash limitation: you cannot pass an array as a single argument. Everything a function receives is strings. There are two solutions:

# a) Expand the array into arguments and collect them with "$@"
show() { local -a items=( "$@" ); printf ' - %s\n' "${items[@]}"; }
show "${cities[@]}"

Simple and enough when the array is the only thing you pass. If you also need to pass other arguments, put them first and use shift.

# b) Nameref: pass the NAME of the array (the only option for associative ones)
summarize_map() {
    local -n _map="$1"           # _map becomes an alias of the real array
    local key
    for key in "${!_map[@]}"; do printf '%-10s %s\n' "$key" "${_map[$key]}"; done
}
summarize_map issues_by_city            # no $, you pass the NAME

The nameref (local -n, introduced in 04-02) is essential with associative arrays, because "${map[@]}" would lose the keys along the way. It is also the way to return an array: the function writes into local -n output="$2" and the caller receives the complete array.

  1. Operations table

Operation Indexed array Associative array
Declare declare -a a (optional) declare -A m (mandatory)
Create a=(x y z) m=([k1]=v1 [k2]=v2)
Assign a[3]="x" m[key]="v"
Read ${a[3]} ${m[key]}
All values "${a[@]}" "${m[@]}"
All keys "${!a[@]}" (numbers) "${!m[@]}" (text, unordered)
Number of elements ${#a[@]} ${#m[@]}
Append a+=(x) m[new]="v"
Does the key exist? [[ -v a[3] ]] [[ -v m[key] ]]
Delete an element unset 'a[3]' (leaves a hole) unset 'm[key]'
Slice / order "${a[@]:1:2}", already ordered not applicable; sort over "${!m[@]}"

  1. Applying it in daily-report.sh

We replace the loop that re-read the CSV per city with a single pass that accumulates three maps:

readonly CITIES=(Valencia Sevilla Bilbao Madrid)
readonly COURIERS=(alopez mgarcia jruiz)
# accumulate_day — Fills the global maps from the CSV for one date.
# Usage: accumulate_day <date>   Returns: 0 | 6 if there is no matching line
accumulate_day() {
    local report_date="${1:?}" f city status amount lines_read=0
    declare -gA SHIPMENTS ISSUES AMOUNT         # -g: global despite being in a function
    while IFS=, read -r _ f city _ status amount; do
        [[ "$f" == "$report_date" ]] || continue
        (( lines_read++ ))
        (( SHIPMENTS["$city"]++ ))
        (( AMOUNT["$city"] += ${amount%.*} ))           # integer part, for now
        [[ "$status" == "issue" ]] && (( ISSUES["$city"]++ ))
    done < <(tail -n +2 "$CSV_PATH")
    (( lines_read > 0 )) || return 6
}

cities_table() {
    local c
    printf '%-10s %8s %13s %10s\n' CITY SHIPMENTS ISSUES AMOUNT
    for c in "${CITIES[@]}"; do
        [[ -v SHIPMENTS[$c] ]] || continue
        printf '%-10s %8d %13d %9d€\n' "$c" "${SHIPMENTS[$c]}" "${ISSUES[$c]:-0}" "${AMOUNT[$c]}"
    done
}

What is new: declare -gA, because inside a function declare creates local variables by default and -g makes them global so that cities_table can see them (it is the alternative to a nameref when the maps are shared by the whole script); the loop walks "${CITIES[@]}" and not the map's keys, so the table always comes out in the same order and not in the arbitrary one of the hash table; and a single pass with no grep at all, so the script goes from launching 8 processes to launching 1.

The obvious point is still pending: ${amount%.*} throws away the cents because Bash does not add decimals. We will explain that expansion formally in the next lesson, and decimals will arrive in 04-06.

Common Mistakes and Tips

  • Forgetting declare -A on an associative array. Bash gives no warning: it turns the keys into arithmetic and everything ends up clobbering index 0. It is mistake number one.
  • Writing ${arr[@]} unquoted. It breaks with any element that has spaces. Always "${arr[@]}".
  • Using $arr thinking it is the whole array. It is only element 0.
  • Expecting order in an associative array's keys. There is none. Sort explicitly or walk a fixed list.
  • arr=( $(command) ). Use mapfile -t arr < <(command).
  • Counting with for (( i=0; i<${#a[@]}; i++ )) over an array with holes. Walk "${!a[@]}".
  • Tip: use UPPERCASE for the script's shared global maps and lowercase for local arrays; telling them apart at a glance avoids collisions.

Exercises

Exercise 1. Create an array with the three couriers, append pcastro at the end, show how many there are, walk it numbered (1) alopez) and show the last two.

Exercise 2. With an associative array, count how many shipments each courier has in shipments.csv in a single pass, and print the result sorted alphabetically by courier.

Exercise 3. Write field_n() that takes a CSV line and a field number, and writes that field to stdout using an array. It must work like this: field_n "E1001,2026-08-03,Valencia,alopez,delivered,24.50" 3Valencia.

Solutions

Solution 1.

couriers=(alopez mgarcia jruiz)
couriers+=(pcastro)
echo "Total: ${#couriers[@]}"                     # Total: 4
for i in "${!couriers[@]}"; do printf '%d) %s\n' "$(( i+1 ))" "${couriers[$i]}"; done
echo "Last two: ${couriers[@]: -2}"               # jruiz pcastro

${!couriers[@]} gives the indexes; we add 1 only to present them starting at 1.

Solution 2.

declare -A by_courier
while IFS=, read -r _ _ _ courier _ _; do (( by_courier["$courier"]++ )); done \
    < <(tail -n +2 /srv/veloz/data/shipments.csv)

for r in $(printf '%s\n' "${!by_courier[@]}" | sort); do
    printf '%-10s %4d\n' "$r" "${by_courier[$r]}"
done

Output: alopez 137, jruiz 98, mgarcia 121. The printf | sort is necessary because the key order is unpredictable.

Solution 3.

# field_n — Extracts a field from a CSV line (1-indexed).
# Usage: field_n <line> <n>   Output: the field   Returns: 0 | 2 if n is out of range
field_n() {
    local line="${1:?}" n="${2:?}"; local -a fields
    IFS=',' read -r -a fields <<< "$line"
    (( n >= 1 && n <= ${#fields[@]} )) || return 2
    printf '%s\n' "${fields[n-1]}"
}

Note ${fields[n-1]}: inside the brackets there is arithmetic context, so neither $ nor $(( )) are needed. The -1 translates from the human index (1) to Bash's (0).

Conclusion

You now have structured memory. Indexed arrays hold ordered lists —cities, couriers, files— and are walked with for x in "${arr[@]}" or by index with "${!arr[@]}"; associative ones, mandatorily declared with declare -A, hold key-to-value maps and make possible the counter pattern that summarizes a whole CSV in a single pass, with the results in variables instead of in text. mapfile -t fills them from a command, IFS=',' read -r -a fills them from a string, and namerefs carry them in and out of functions.

Along the way two expansions have shown up that we used without justification: ${amount%.*} to keep the integer part and ${var:-0} for the default value. They belong to the same string manipulation family you started in 03-06 and that the next lesson completes: cutting substrings, stripping prefixes and suffixes, substituting text, changing case and formatting tables with printf. All of it without launching a single external process, which is the difference between a script that takes two seconds and one that takes two minutes.

Bash Programming Course

Module 1: Introduction to Bash

Module 2: Basic Bash Commands

Module 3: Scripting Fundamentals

Module 4: Intermediate Scripting

Module 5: Advanced Scripting Techniques

Module 6: Working with External Tools

Module 7: Automation and Scheduling

Module 8: Best Practices and Optimization

Module 9: Real-World Projects

© Copyright 2026. All rights reserved