Zsh Shell Tutorial 12: Arrays — Create, Modify & Iterate Collections
Video: Zsh Shell Tutorial 12: Arrays — Create, Modify & Iterate Collections by Taught by Celeste AI - AI Coding Coach
Zsh Lesson 12: Arrays — Create, Modify, Iterate
arr=(a b c). Index with$arr[1](Zsh is 1-based, unlike Bash).$#arrfor length.${arr[@]}for all elements (portable form).arr+=(x)to append.declare -Afor associative arrays (string keys).
Arrays let you hold multiple values in one variable. Zsh's array semantics are slightly different from Bash — pay attention.
Creating an array
fruits=(apple banana cherry date)
echo "$fruits"
# apple banana cherry date — Zsh joins with spaces by default
Space-separated values inside parentheses. No commas.
Indexing (Zsh is 1-based!)
echo "$fruits[1]" # apple — first element
echo "$fruits[2]" # banana
echo "$fruits[3]" # cherry
echo "$fruits[-1]" # date — last element
This is the biggest gotcha for Bash users: Zsh arrays are 1-indexed. Bash is 0-indexed. The same code behaves differently in each shell.
For portability, write code that works in both:
echo "${fruits[1]}" # both shells: ✓ but means different elements
If you need cross-shell, prefer iteration over indexing.
Length
echo "$#fruits" # 4
echo "${#fruits}" # also 4
echo "$#fruits[1]" # length of first element (Zsh)
echo "${#fruits[1]}" # 5 — length of "apple"
$#name is the array length; ${#name[i]} is the length of element i.
All elements
echo "$fruits" # apple banana cherry date (Zsh: implicit spread)
echo "${fruits[@]}" # same — portable form
echo "${fruits[*]}" # joined by IFS — usually space
In Zsh, bare $arr already expands to all elements. In Bash, you'd need ${arr[@]}. Use "${arr[@]}" for cross-shell scripts.
Joining with a separator
echo "${(j:, :)fruits}"
# apple, banana, cherry, date
(j:sep:) is a Zsh parameter expansion flag — joins with sep. Useful for output:
echo "Found: ${(j: | :)results}"
For Bash compatibility, use the IFS trick:
old_ifs="$IFS"; IFS=', '
echo "${fruits[*]}"
IFS="$old_ifs"
Appending
fruits+=(elderberry)
fruits+=(fig grape) # multiple at once
echo "$fruits"
# apple banana cherry date elderberry fig grape
arr+=(items) adds to the end. Doesn't modify in place — just adds.
Replacing an element
colors=(red green blue)
colors[2]="lime" # 2nd element is now "lime"
Direct assignment by index.
Slicing
colors=(red green blue yellow purple)
echo "$colors[2,4]" # green blue yellow — elements 2 to 4
echo "$colors[3,-1]" # blue yellow purple — element 3 to end
echo "$colors[1,3]" # red green blue — first 3
Inclusive on both ends. Negative indices count from the end.
Removing an element
colors=(red green blue yellow)
colors[3]=() # remove blue
echo "$colors" # red green yellow
echo "$#colors" # 3
Assigning () to an indexed slot removes it. The array compacts.
To remove a slice:
colors[2,3]=() # remove indices 2-3
Iteration
languages=(Python JavaScript Go Rust)
for lang in $languages; do
echo "I know $lang"
done
In Zsh, bare $arr works in for. Bash requires "${arr[@]}":
for lang in "${languages[@]}"; do # portable
echo "$lang"
done
Iterating with index
for i in {1..$#languages}; do
echo "$i. $languages[$i]"
done
{1..$#arr} generates indices 1 to length. Common pattern when you need both index and value.
Building an array dynamically
files=($(ls *.sh 2>/dev/null))
echo "Found $#files scripts"
Capture command output as words → array elements. But: word splitting depends on IFS, and filenames with spaces break this.
For files specifically, use a glob:
files=(*.sh) # safe for spaces — Zsh does the work
echo $#files
Filtering
long_names=()
for lang in $languages; do
if (( ${#lang} >= 4 )); then
long_names+=($lang)
fi
done
echo "$long_names"
Build a new array conditionally. Standard pattern.
Or more concisely with parameter expansion (Zsh):
long_names=(${(M)languages:#????*}) # match anything 4+ chars
(Cryptic — readability over cleverness usually wins.)
Sorting
colors=(red green blue yellow purple)
echo "${(o)colors}" # blue green purple red yellow — sorted
echo "${(O)colors}" # reverse-sorted
echo "${(oi)colors}" # case-insensitive sort
(o) and (O) are Zsh expansion flags. For Bash, sort via printf + sort:
sorted=($(printf '%s\n' "${colors[@]}" | sort))
Associative arrays (string keys)
declare -A user
user[name]="Alice"
user[age]="30"
user[city]="Portland"
echo "$user[name]" # Alice
echo "$user[city]" # Portland
declare -A name creates an associative array. Without it, you'd get an indexed array.
All keys, all values
echo "${(k)user}" # name age city — all keys
echo "${(v)user}" # Alice 30 Portland — all values
for key in ${(k)user}; do
echo "$key: $user[$key]"
done
Adding/removing entries
user[email]="alice@example.com" # add
user[city]="Seattle" # update
unset 'user[age]' # remove
Checking key existence
if [[ -v 'user[name]' ]]; then
echo "name is set"
fi
if (( ${+user[name]} )); then # Zsh-specific
echo "name is set"
fi
-v VAR returns true if VAR is set. ${+VAR} returns 1 if set, 0 if not.
Length of associative
echo "$#user" # number of entries
A real-world example
#!/usr/bin/env zsh
set -euo pipefail
# Count file extensions in current dir
declare -A counts
for file in *(.); do # *(.) = files only (Zsh glob qualifier)
ext="${file##*.}" # get extension
counts[$ext]=$((${counts[$ext]:-0} + 1))
done
# Print sorted by count, descending
for ext in ${(k)counts}; do
printf "%-10s %d\n" "$ext" "${counts[$ext]}"
done | sort -k2 -rn
Build a histogram of file extensions. Uses associative array for counting; ${counts[$ext]:-0} defaults to zero on first access.
Bash vs Zsh: key differences
| Feature | Zsh | Bash |
|---|---|---|
| Index base | 1 | 0 |
$arr |
spreads to all | first element only |
arr[-1] |
last element | last (4.3+) |
| Glob qualifiers | *(.), *(/), etc. |
not supported |
| Sort/expand flags | ${(o)arr}, etc. |
not supported |
declare -A |
required for assoc | required |
| Length | $#arr |
${#arr[@]} |
For cross-shell scripts, write to the Bash subset.
Common stumbles
Bash habits. $arr in Bash gives the first element; in Zsh, all of them. Use "${arr[@]}" for portability.
Indexing 0. $arr[0] in Zsh is empty. $arr[1] is first. (In Bash, $arr[0] is first.)
Word splitting on assignment. arr=$(ls) is a string, not an array. Use arr=($(ls)) — but watch out for spaces.
Filenames with spaces. for f in $(ls) splits on spaces. Use globs: for f in *.txt.
declare -A forgetting. Without it, user[name]=Alice either errors or treats name as 0. Always declare -A name first.
$#arr vs ${#arr}. Both work in Zsh. Bash also uses ${#arr[@]}. For portability: ${#arr[@]}.
Sparse arrays. Removing arr[3] in Zsh compacts the array. In Bash, it leaves a "hole." Different mental models.
Slicing inclusive. arr[1,3] in Zsh is elements 1, 2, 3. In Bash, the form is ${arr[@]:1:3} (start, length).
What's next
Lesson 13: string operations. Substrings, replacement, case conversion, splitting/joining.
Recap
arr=(a b c). Index $arr[i] (Zsh 1-based). $#arr length, $arr[-1] last, $arr[i,j] slice. arr+=(x) append, arr[i]=() remove. for x in $arr iterates (Zsh). declare -A name for associative arrays. ${(k)arr} keys, ${(v)arr} values. ${(o)arr} sorted. Zsh ≠ Bash on indexing — use "${arr[@]}" for portability.
Next lesson: string operations.