Back to Blog

Zsh Shell Tutorial 12: Arrays — Create, Modify & Iterate Collections

Sandy LaneSandy Lane

Video: Zsh Shell Tutorial 12: Arrays — Create, Modify & Iterate Collections by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Zsh Lesson 12: Arrays — Create, Modify, Iterate

arr=(a b c). Index with $arr[1] (Zsh is 1-based, unlike Bash). $#arr for length. ${arr[@]} for all elements (portable form). arr+=(x) to append. declare -A for 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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.