If you’ve spent years writing shell that pretends arrays don’t exist — using space-separated strings as fake lists, parsing the output of ls, or shoving “structured” data into colon-separated KEY=VALUE blobs — this is the lesson that changes how you write shell. Bash has had real indexed arrays since the early 90s and real associative arrays (hashes) since version 4 (released 2009). They are first-class values, fast, and dramatically safer than every string-based hack people use to avoid them.
This lesson covers both kinds of array, the iteration patterns that go with them, the mapfile/readarray builtin for reading lines into an array safely, the all-important "${arr[@]}" vs "${arr[*]}" distinction, and the file-list collection pattern that should replace every for f in $(ls) you’ve ever written.
A few macOS users will hit a wall here: macOS ships with bash 3.2 (it’s been frozen there since 2007 because of GPLv3 licensing). Associative arrays don’t work on stock macOS bash. The fix is brew install bash, then either chsh -s /opt/homebrew/bin/bash or just call your scripts with the new bash. Every example in this lesson assumes bash 4+; we’ll note bash 3.2 limitations where they matter.
1. Indexed arrays: the basics
An indexed array is an ordered collection of strings, indexed by non-negative integers starting at 0. Bash arrays are sparse — you can have indices 0, 1, 5, 100 with nothing in between, and the array still works.
Declaration and assignment
# Implicit declaration with the parentheses syntax
FRUITS=(apple banana cherry)
# Explicit declaration
declare -a FRUITS=(apple banana cherry)
# Empty array
EMPTY=()
# With explicit indices (allows sparse arrays)
SPARSE=([0]=zero [5]=five [10]=ten)
# With computed values
TODAY=$(date +%a)
DAYS=(Mon Tue Wed Thu Fri Sat Sun "$TODAY")
The parentheses-with-spaces syntax is the canonical form. Each space-separated token becomes one element. The same word-splitting and quoting rules from L2 apply — quote elements that contain spaces:
NAMES=(alice bob "carol jones" david)
echo "${#NAMES[@]}" # 4 — four elements
echo "${NAMES[2]}" # carol jones
If you wrote NAMES=(alice bob carol jones david) (no quotes around "carol jones"), you’d get five elements with carol and jones as separate tokens.
Accessing elements
FRUITS=(apple banana cherry)
echo "${FRUITS[0]}" # apple — first element (index 0!)
echo "${FRUITS[1]}" # banana
echo "${FRUITS[2]}" # cherry
echo "${FRUITS[-1]}" # cherry — bash 4.3+: negative indices count from end
echo "${FRUITS[-2]}" # banana
echo "${FRUITS}" # apple — same as ${FRUITS[0]}, NOT all elements!
The last form is a notorious pitfall. $FRUITS and ${FRUITS} and ${FRUITS[0]} are all the same — they give you the first element, not the array. To get all elements, you need the special [@] or [*] index. We’ll cover the difference between those in the next section.
Getting all elements
FRUITS=(apple banana cherry)
echo "${FRUITS[@]}" # apple banana cherry — all elements as separate words
echo "${FRUITS[*]}" # apple banana cherry — all elements joined by IFS[0]
echo "${#FRUITS[@]}" # 3 — number of elements
echo "${!FRUITS[@]}" # 0 1 2 — list of indices (note the leading !)
The [@] form expands to one shell-word per element. The [*] form joins all elements with the first character of IFS (default: a space). When unquoted, the difference is invisible. When quoted, the difference is everything. Section 4 of this lesson is dedicated to that distinction — don’t skim it.
Appending
FRUITS=(apple banana)
FRUITS+=(cherry) # append one
FRUITS+=(date elderberry) # append several
echo "${FRUITS[@]}" # apple banana cherry date elderberry
The += operator with (...) appends. The parentheses are essential — without them, += does string concatenation on the first element only:
FRUITS+=cherry # WRONG: appends "cherry" to ${FRUITS[0]}, giving "applecherry"
echo "${FRUITS[0]}" # applecherry
Always use arr+=(value) to append, not arr+=value.
Modifying and deleting
FRUITS=(apple banana cherry)
FRUITS[1]="berry" # replace index 1
echo "${FRUITS[@]}" # apple berry cherry
unset 'FRUITS[1]' # remove index 1
echo "${FRUITS[@]}" # apple cherry
echo "${#FRUITS[@]}" # 2
echo "${!FRUITS[@]}" # 0 2 — note: 1 is gone, 2 didn't shift down (sparse!)
unset 'FRUITS[1]' removes the element but does not renumber the remaining elements. The array is now sparse. This is sometimes surprising. To get a “compact” array after deletion:
FRUITS=("${FRUITS[@]}") # rebuild without gaps
echo "${!FRUITS[@]}" # 0 1 — now indices are contiguous
The quoting around 'FRUITS[1]' matters: bash’s globbing might expand [1] as a character class against files in your current directory, breaking the unset. Always quote the argument to unset for arrays.
To delete the entire array:
unset FRUITS # delete the variable entirely
FRUITS=() # set to empty (variable still defined as array)
2. Iterating over arrays
The canonical iteration form:
FRUITS=(apple banana cherry)
for fruit in "${FRUITS[@]}"; do
echo "$fruit"
done
Notice the double quotes around "${FRUITS[@]}". This expansion produces one quoted shell-word per array element, preserving every byte of every element including spaces, tabs, newlines. This is the only correct iteration form for arrays.
Without quotes:
NAMES=(alice "bob jones" carol)
for n in ${NAMES[@]}; do # WRONG: bash word-splits each element, giving 4 names
echo "$n"
done
# Output:
# alice
# bob
# jones
# carol
With quotes:
for n in "${NAMES[@]}"; do # CORRECT: 3 names preserved
echo "$n"
done
# Output:
# alice
# bob jones
# carol
This is the same lesson as L2 — quoting suppresses word splitting. Arrays don’t change the rule.
Iterating with index access
FRUITS=(apple banana cherry)
for i in "${!FRUITS[@]}"; do
echo "${i}: ${FRUITS[$i]}"
done
# Output:
# 0: apple
# 1: banana
# 2: cherry
${!FRUITS[@]} expands to the list of indices. Useful when you need both the index and the value (like Python’s enumerate).
Iterating with a counter
FRUITS=(apple banana cherry)
for ((i=0; i<${#FRUITS[@]}; i++)); do
echo "${i}: ${FRUITS[$i]}"
done
C-style loop for when you want to skip elements, walk in reverse, or process in pairs:
# Process in pairs of two
PAIRS=(name alice age 30 role engineer)
for ((i=0; i<${#PAIRS[@]}; i+=2)); do
echo "${PAIRS[$i]} = ${PAIRS[$i+1]}"
done
For most cases, for x in "${arr[@]}" is cleaner. Drop to indexed iteration only when you need the index for arithmetic.
3. Associative arrays (bash 4+ hashes)
An associative array (also called a “hash” or “map”) indexes by string keys rather than integer positions. You declare them with declare -A:
declare -A USER
USER[name]="alice"
USER[age]=30
USER[role]="engineer"
echo "${USER[name]}" # alice
echo "${USER[age]}" # 30
Or with the parentheses form:
declare -A CAPITALS=(
[USA]=Washington
[UK]=London
[France]=Paris
[Japan]=Tokyo
)
echo "${CAPITALS[USA]}" # Washington
echo "${CAPITALS[Japan]}" # Tokyo
Critical: you must declare -A before assigning. Without declare -A, bash treats USER[name]=alice as an indexed-array assignment with the string name evaluated as an arithmetic expression (which gives 0). You’d get USER[0]=alice — silently wrong.
# WRONG — without declare -A first
USER[name]="alice" # assigns to USER[0] because "name" arithmetic-evaluates to 0
echo "${USER[anything]}" # also "alice" — every key looks like 0
echo "${USER[name]}" # alice (still index 0)
# RIGHT
declare -A USER
USER[name]="alice"
echo "${USER[name]}" # alice
echo "${USER[role]:-unknown}" # unknown
This is one of the most common bugs in associative-array code. Always declare -A first. When in doubt, do it explicitly at the top of your function:
process_user() {
declare -A user # local-scoped associative array
user[name]="$1"
user[age]="$2"
# ...
}
In bash 4.4+ you can combine: declare -A is implicitly local inside a function, but local -A user is more explicit and works in all bash 4.x.
Iterating
Iteration is the same shape as indexed arrays:
declare -A CAPITALS=(
[USA]=Washington
[UK]=London
[France]=Paris
)
# Iterate over keys
for country in "${!CAPITALS[@]}"; do
echo "${country}: ${CAPITALS[$country]}"
done
The ${!ARR[@]} form gives the list of keys for both indexed and associative arrays — for indexed, those are integers; for associative, they’re strings.
Key order is unspecified. Bash does not guarantee any particular ordering for associative-array iteration. If you need sorted output:
for country in $(echo "${!CAPITALS[@]}" | tr ' ' '\n' | sort); do
echo "${country}: ${CAPITALS[$country]}"
done
Or, more robustly with a real array:
mapfile -t SORTED_KEYS < <(printf '%s\n' "${!CAPITALS[@]}" | sort)
for country in "${SORTED_KEYS[@]}"; do
echo "${country}: ${CAPITALS[$country]}"
done
Membership test
declare -A FLAGS=([verbose]=1 [debug]=1)
if [[ -v FLAGS[verbose] ]]; then
echo "verbose is set"
fi
if [[ -n "${FLAGS[debug]+x}" ]]; then
echo "debug exists (POSIX-friendly form)"
fi
The -v test (bash 4.2+) checks “is this variable/key set” — works for both regular variables and array keys. The ${VAR+x} form is the older trick: expands to the literal x if VAR is set, empty if not.
Counting
echo "${#CAPITALS[@]}" # number of key-value pairs
Removing a key
unset 'CAPITALS[USA]' # delete one key
echo "${!CAPITALS[@]}" # USA is gone
The same quoting rule applies: quote the argument to prevent globbing.
4. The "${arr[@]}" vs "${arr[*]}" distinction — read this carefully
This is the single most-misunderstood piece of array syntax in bash. It also appears for "$@" vs "$*" (which we covered in L2), and for the same reason. The rule is clean once you see it.
ARR=(a b "c d" e)
| Expansion | Behaviour |
|---|---|
${ARR[@]} |
Splits each element on IFS; effectively gives 5 words: a b c d e |
${ARR[*]} |
Joins all elements with first char of IFS, then splits on IFS; same 5 words |
"${ARR[@]}" |
Each element becomes one quoted word; 4 args: a, b, c d, e |
"${ARR[*]}" |
All elements joined by first char of IFS; 1 quoted word: a b c d e |
The rule:
"${arr[@]}"— use this when forwarding array elements as separate arguments to commands. Each element stays distinct."${arr[*]}"— use this when you want the array flattened to a single string with elements joined by IFS. Logging, debug output, single-line summaries.
Practical examples:
ARGS=(--verbose --port 8080 "--name=Alice Smith")
# CORRECT — 4 separate arguments, "--name=Alice Smith" stays intact
my-tool "${ARGS[@]}"
# WRONG — 1 argument: "--verbose --port 8080 --name=Alice Smith"
my-tool "${ARGS[*]}"
# WRONG — 5+ arguments because "--name=Alice Smith" word-splits
my-tool ${ARGS[@]}
When in doubt: use "${arr[@]}". The [*] form is correct only when you specifically want a single string.
Joining elements with a custom separator
The first character of IFS controls the join character for "${arr[*]}". So you can join with any character:
ARR=(one two three)
IFS=',' ; echo "${ARR[*]}" # one,two,three
IFS=' | ' ; echo "${ARR[*]}" # one |two |three (only first char of IFS used)
IFS=$'\n' ; echo "${ARR[*]}" # one<NL>two<NL>three
The first-character rule is annoying — you can’t join with a multi-character separator this way. For multi-char joins, use printf or a loop:
join_by() {
local sep="$1"; shift
local out=""
local first=1
local elem
for elem in "$@"; do
if (( first )); then
out="$elem"
first=0
else
out+="${sep}${elem}"
fi
done
printf '%s' "$out"
}
ARR=(one two three)
join_by ' | ' "${ARR[@]}" # one | two | three
This is a common helper to keep around.
5. Slicing and substring operations
Bash supports slicing on arrays with the same syntax as substring extraction on strings (covered in L2).
ARR=(zero one two three four five)
echo "${ARR[@]:1:3}" # one two three — start at index 1, take 3 elements
echo "${ARR[@]:2}" # two three four five — start at index 2 to end
echo "${ARR[@]: -2}" # four five — last 2 (note the leading space!)
The slice syntax: ${arr[@]:OFFSET:LENGTH}. Both are arithmetic expressions. Negative offsets count from the end (need a leading space to disambiguate from :-default).
For associative arrays, slicing on [@] doesn’t quite work the same way (key order is unspecified), but slicing on the list of keys does:
declare -A USER=([name]=alice [age]=30 [role]=engineer)
KEYS=("${!USER[@]}") # snapshot keys into an indexed array
echo "${KEYS[@]:0:2}" # first two keys (whatever those are)
String operations on array elements
All the parameter-expansion operators from L2 work on array elements — and on the entire array at once with [@].
PATHS=(/var/log/a.log /var/log/b.log /tmp/c.log)
# Apply a transformation to every element
echo "${PATHS[@]##*/}" # a.log b.log c.log — basename of every element
echo "${PATHS[@]%.log}" # /var/log/a /var/log/b /tmp/c — strip .log suffix
echo "${PATHS[@]/log/LOG}" # /var/LOG/a.log /var/LOG/b.log /tmp/c.LOG — replace first "log" each
echo "${PATHS[@]//log/LOG}" # /var/LOG/a.LOG /var/LOG/b.LOG /tmp/c.LOG — replace all "log"s
This is enormously powerful. You can do basename, dirname, replacement, case folding, and length operations across an entire array in one expansion, with no fork. It’s much faster than piping through sed or awk.
# Fast: bash builtin, no fork
LOWERED=("${ARR[@]}")
LOWERED=("${LOWERED[@],,}") # all lowercase
# Slow: forks one awk per element
LOWERED=()
for x in "${ARR[@]}"; do
LOWERED+=("$(echo "$x" | tr '[:upper:]' '[:lower:]')")
done
In a loop with thousands of iterations, the bash-builtin form can be 100× faster.
6. mapfile (also called readarray) — the right way to load files into arrays
mapfile -t ARR < FILE reads a file line by line and stores each line as one array element. The -t flag strips trailing newlines from each line (almost always what you want).
mapfile -t LINES < /etc/hostname
echo "${LINES[0]}" # the hostname
echo "${#LINES[@]}" # 1
mapfile -t USERS < users.txt
for u in "${USERS[@]}"; do
echo "Processing user: $u"
done
mapfile is the modern, byte-safe replacement for while read line; do arr+=("$line"); done and the various arr=( $(cat file) ) hacks people write. It handles every line correctly: leading whitespace, trailing whitespace, embedded glob characters — every edge case is handled by being byte-exact and not subject to word splitting.
Reading from a command instead of a file
mapfile -t SERVICES < <(systemctl list-units --type=service --no-legend | awk '{print $1}')
echo "${#SERVICES[@]}"
The < <(cmd) is process substitution (covered in L4 and revisited in L7). It runs cmd and presents its output as a file the array can read from. The whole thing happens in the parent shell, so the array is populated correctly (no subshell trap from L4).
Useful mapfile flags
-t— strip trailing newlines (almost always wanted).-n N— read at most N lines.-s N— skip the first N lines.-d DELIM— use DELIM instead of newline (bash 4.4+). With-d ''(empty), reads NUL-separated input. Combine withfind -print0for filename-safe iteration.-O N— start storing at index N (so you can append to an existing array).-c Nand-C CALLBACK— call CALLBACK every N lines (rarely used).
The NUL-separated form is essential for filename-safe collection:
mapfile -d '' -t FILES < <(find /var/log -type f -name '*.log' -print0)
for f in "${FILES[@]}"; do
echo "Processing: $f"
done
This handles every legal filename, including ones with newlines or weird characters. This is the right way to collect a list of files into an array — far better than parsing find’s output as text.
readarray is exactly the same builtin as mapfile. Bash defines them as aliases. Use whichever name you prefer; this course uses mapfile.
7. Sorting, deduplicating, and the read-into-array idioms
Bash has no built-in sort. You shell out to sort:
NAMES=(charlie alice bob alice david)
# Sort alphabetically, in place
mapfile -t SORTED < <(printf '%s\n' "${NAMES[@]}" | sort)
echo "${SORTED[@]}" # alice alice bob charlie david
# Sort and dedupe
mapfile -t UNIQUE < <(printf '%s\n' "${NAMES[@]}" | sort -u)
echo "${UNIQUE[@]}" # alice bob charlie david
# Numeric sort
NUMBERS=(10 2 30 4 100)
mapfile -t SORTED_NUM < <(printf '%s\n' "${NUMBERS[@]}" | sort -n)
echo "${SORTED_NUM[@]}" # 2 4 10 30 100
# Reverse sort
mapfile -t SORTED_REV < <(printf '%s\n' "${NAMES[@]}" | sort -r)
The printf '%s\n' "${arr[@]}" idiom is worth memorising — it prints each array element on its own line, byte-exact. Combined with mapfile -t ... < <(...), it gives you a clean “transform array via Unix tool” pipeline.
Deduplication preserving original order
NAMES=(charlie alice bob alice david bob)
mapfile -t UNIQUE < <(printf '%s\n' "${NAMES[@]}" | awk '!seen[$0]++')
echo "${UNIQUE[@]}" # charlie alice bob david
awk '!seen[$0]++' is a famous one-liner: tracks each line in seen, prints it the first time only. Preserves original order, unlike sort -u.
Membership test (linear scan)
Bash has no built-in “is this element in the array” check. The simple form:
contains() {
local needle="$1"; shift
local x
for x in "$@"; do
[[ "$x" == "$needle" ]] && return 0
done
return 1
}
FRUITS=(apple banana cherry)
if contains "banana" "${FRUITS[@]}"; then
echo "found"
fi
This is O(N) — fine for arrays under a few thousand elements. For larger collections where membership tests are frequent, use an associative array as a set:
declare -A FRUIT_SET=([apple]=1 [banana]=1 [cherry]=1)
if [[ -n "${FRUIT_SET[banana]:-}" ]]; then
echo "found"
fi
O(1) lookup. The associative array is the right choice for set semantics.
8. The file-list collection pattern
This is the canonical pattern this course wants you to know cold. It replaces every “iterate over files” hack you’ve ever seen.
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# Step 1: collect files into an array, NUL-safe
mapfile -d '' -t FILES < <(find /var/log -type f -name '*.log' -print0)
# Step 2: report the count before doing anything destructive
echo "Found ${#FILES[@]} log files"
# Step 3: iterate safely
for f in "${FILES[@]}"; do
echo "Processing: $f"
# do something with $f
done
# Step 4: bulk operations are also safe
gzip -- "${FILES[@]}"
Why this is the right shape:
find -print0+mapfile -d ''handles every legal filename (including newlines, glob chars, spaces).-type ffilters out directories and weird filesystem entries before we even look at them.- The intermediate array is inspectable. You can
echo "${#FILES[@]}"to see how many you got, before doing anything destructive. This is the single most-important guard against accidental mass-modification. gzip -- "${FILES[@]}"invokesgziponce with all filenames as arguments, instead of one fork per file. Hugely faster for many files.
Compare to the wrong forms you may have written before:
for f in $(ls *.log); do gzip "$f"; done # WRONG: spaces, globs, fork-per-file
for f in *.log; do gzip "$f"; done # OK for one directory; no recursion; doesn't handle empty match
find . -name '*.log' -exec gzip {} \; # forks per file; slower
find . -name '*.log' | while read f; do ...; done # subshell trap from L4
The array-pattern form gets all of these right and is the canonical advanced-shell idiom.
9. Real-world example: a service-status report
#!/usr/bin/env bash
# service-report.sh — print a sorted table of services with their statuses
set -euo pipefail
IFS=$'\n\t'
# 1. Collect service names into an indexed array
mapfile -t SERVICES < <(systemctl list-units --type=service --no-legend --no-pager \
| awk '{print $1}' | sort)
# 2. Build a parallel associative array of statuses
declare -A STATUS
for svc in "${SERVICES[@]}"; do
STATUS[$svc]=$(systemctl is-active "$svc" 2>/dev/null || echo "unknown")
done
# 3. Print a summary
COUNT_RUNNING=0
COUNT_FAILED=0
COUNT_OTHER=0
printf '%-50s %s\n' "Service" "Status"
printf '%-50s %s\n' "-------" "------"
for svc in "${SERVICES[@]}"; do
printf '%-50s %s\n' "$svc" "${STATUS[$svc]}"
case "${STATUS[$svc]}" in
active) (( COUNT_RUNNING++ )) ;;
failed) (( COUNT_FAILED++ )) ;;
*) (( COUNT_OTHER++ )) ;;
esac
done
# 4. Footer
echo
echo "Running: $COUNT_RUNNING"
echo "Failed: $COUNT_FAILED"
echo "Other: $COUNT_OTHER"
# 5. Exit non-zero if anything is failed
(( COUNT_FAILED == 0 ))
Things to notice:
mapfile -t SERVICES < <(...)to load services safely.declare -A STATUSfor the parallel hash.- Iteration uses
"${SERVICES[@]}"and${STATUS[$svc]}— every quoting rule is in play. - Counting in the parent shell (no pipe-into-while subshell trap).
- The script’s own exit code reflects the failure count: success only if zero failed.
- Strict mode and IFS hardening at the top.
This is the shape of structured shell code with arrays. It’s clean, fast, byte-safe, and readable.
10. Pitfalls and edge cases
Bash 3.2 (macOS) — no associative arrays
If your script must run on stock macOS bash, you can’t use declare -A. Workarounds:
- Use parallel indexed arrays: one for keys, one for values.
- Use a serialised string with a separator:
KEY1=value1;KEY2=value2. - Install bash 4+ via Homebrew:
brew install bash.
The cleanest fix for any non-trivial script is brew install bash and shebang as #!/usr/bin/env bash (which finds /opt/homebrew/bin/bash ahead of /bin/bash). Don’t try to write portable shell that supports bash 3.2; it’s not worth the contortions.
The [*] vs [@] pitfall, again
ARR=(one "two three" four)
cmd ${ARR[@]} # 4 args: one two three four
cmd ${ARR[*]} # 4 args: one two three four (same — IFS-split after expansion)
cmd "${ARR[@]}" # 3 args: one, two three, four
cmd "${ARR[*]}" # 1 arg: "one two three four"
Default to "${ARR[@]}". Use "${ARR[*]}" only when you specifically want a single joined string.
${ARR} is not the array
ARR=(one two three)
echo "${ARR}" # "one" — first element only
echo "${ARR[@]}" # "one two three" — all elements
A bare $ARR or ${ARR} is ${ARR[0]}. This is a constant source of bugs; if you mean “the array”, spell it "${ARR[@]}".
Spaces around = in declare -A
The same rule as L2 — no spaces around =. But there’s a twist for arrays:
declare -A USER=([name]=alice) # WORKS
declare -A USER = ([name]=alice) # WRONG: bash interprets this differently
# Inside an array assignment, no quotes around the key in [...]:
declare -A USER=([name]="Alice Smith") # WORKS
# Quoted key works too:
declare -A USER=(["name"]="Alice Smith") # WORKS
When in doubt, write the elements one per line for clarity:
declare -A USER=(
[name]="Alice Smith"
[age]=30
[role]=engineer
)
Iterating over associative arrays gives keys, not values
declare -A CAPITALS=([USA]=Washington [UK]=London)
for c in "${CAPITALS[@]}"; do # Washington London (values, in indeterminate order)
echo "$c"
done
for c in "${!CAPITALS[@]}"; do # USA UK (keys)
echo "$c -> ${CAPITALS[$c]}"
done
Almost always you want ${!ARR[@]} (keys) and look up the value by key. Pure value iteration is rare for hashes.
Sorting is always external
There is no built-in array sort in bash. You always go through sort. The pattern is:
mapfile -t SORTED < <(printf '%s\n' "${UNSORTED[@]}" | sort [-options])
For numeric sort, sort -n. For reverse, sort -r. For sort-and-dedupe, sort -u. For preserving original order while deduping, awk '!seen[$0]++'.
11. Fourteen array idioms to memorise
# 1. Declare and populate
ARR=(one two three)
# 2. Append
ARR+=(four)
ARR+=(five six)
# 3. Length
COUNT="${#ARR[@]}"
# 4. All elements (the canonical iteration)
for x in "${ARR[@]}"; do
echo "$x"
done
# 5. List of indices / keys
for i in "${!ARR[@]}"; do
echo "${i}: ${ARR[$i]}"
done
# 6. Slice
FIRST_TWO=("${ARR[@]:0:2}")
# 7. Last element (bash 4.3+)
LAST="${ARR[-1]}"
# 8. Apply transformation across array
LOG_PATHS=(/var/log/a.log /var/log/b.log)
BASENAMES=("${LOG_PATHS[@]##*/}") # a.log b.log
# 9. Read file lines into array
mapfile -t LINES < file.txt
# 10. Read NUL-separated input (filename-safe)
mapfile -d '' -t FILES < <(find /path -type f -print0)
# 11. Sort
mapfile -t SORTED < <(printf '%s\n' "${ARR[@]}" | sort)
# 12. Deduplicate, preserving order
mapfile -t UNIQUE < <(printf '%s\n' "${ARR[@]}" | awk '!seen[$0]++')
# 13. Associative array (set membership)
declare -A SET=([apple]=1 [banana]=1)
[[ -n "${SET[banana]:-}" ]] && echo "in set"
# 14. Forwarding to a command
my-command "${ARR[@]}"
Internalise these and you’ve replaced 90% of the string-hack code you’d otherwise write.
12. What you must internalise before lesson 7
- Why is
$ARRnot the same as"${ARR[@]}"? ($ARRis just${ARR[0]}; the array expansion needs[@].) - What’s the difference between
"${ARR[@]}"and"${ARR[*]}"? ([@]expands to one quoted word per element;[*]joins all elements with first char of IFS into one word.) - Why must you
declare -Abefore assigning to an associative array? (Otherwise bash treats keys as arithmetic expressions, silently giving every key the index 0.) - What does
mapfile -t -d '' ARR < <(find ... -print0)do? (Loads NUL-separated input — every legal filename — into ARR, line by line, with newlines stripped.) - What does
${PATHS[@]##*/}do? (Apply parameter expansion across every array element — here, basename.) - How do you remove an element? (
unset 'ARR[index]'. Quote the argument to prevent globbing.) - What’s the right way to test if an element is in an associative array? (
[[ -v ARR[key] ]]in bash 4.2+, or[[ -n "${ARR[key]+x}" ]]for older bash.) - How do you sort an array? (External:
mapfile -t SORTED < <(printf '%s\n' "${ARR[@]}" | sort).) - How do you dedupe preserving original order? (
awk '!seen[$0]++'.) - What’s the right way to forward array elements to a command? (
cmd "${ARR[@]}"— never unquoted, never[*].)
If any felt fuzzy, re-read. With L1–L6 you have all of bash’s data-handling primitives. L7 takes you into I/O — file descriptors, redirection, here-docs, process substitution.
What’s next
Lesson 7 covers I/O redirection in depth: file descriptors (0, 1, 2 and beyond), the < > >> 2> &> 2>&1 operators, here-docs (<<EOF), here-strings (<<<), tee, exec for FD remapping, and process substitution as the elegant alternative to temporary files. Bring everything from L1–L6 — every redirection is a process-and-quoting decision.