← Back to DevBytes

Zsh Scripting: Variables Complete Guide

Introduction to Zsh Variables

Zsh (Z shell) is a powerful Unix shell that extends the Bourne shell syntax with many improvements, and variables are the foundation of any Zsh script. A variable is simply a named storage location in memory that holds a value, which can be a string, number, array, or associative array. Understanding how to declare, assign, scope, and manipulate variables is essential for writing robust, maintainable shell scripts.

What Are Zsh Variables?

In Zsh, a variable is created the moment you assign a value to it. Unlike some languages, Zsh is dynamically typed, meaning you do not declare a type explicitly. Variables can hold scalar values (strings or numbers), indexed arrays, or associative arrays (key-value pairs). Zsh also introduces several unique features compared to Bash, such as more flexible parameter expansion and built-in array handling.

Why Variables Matter in Zsh Scripting

Variables matter because they allow scripts to be dynamic rather than static. They let you store user input, configuration values, command output, and intermediate results. Without variables, every script would be a rigid sequence of hardcoded commands. With them, you can write reusable functions, build loops, conditionally branch logic, and pass data between processes. Mastering variables is the first step toward writing professional-grade automation in Zsh.

Declaring and Assigning Variables

Declaring a variable in Zsh is straightforward: use the assignment operator = with no spaces around it. To read the value, prefix the variable name with a dollar sign $.

#!/usr/bin/env zsh

# Basic scalar assignment
name="Zsh"
version=5.9

# Reading values
echo "Shell: $name"
echo "Version: $version"

Note that version=5.9 is still treated as a string by default. Zsh does not enforce numeric types, but it can perform arithmetic when you use the appropriate syntax.

Quoting and Word Splitting

When assigning values that contain spaces or special characters, you must quote them. Zsh, unlike Bash, does not perform word splitting on unquoted variable expansions by default, which is one of its safer behaviors. However, quoting is still recommended for clarity and portability.

#!/usr/bin/env zsh

greeting="Hello, World"
path_with_spaces="/Users/john doe/scripts"

# Single quotes prevent all expansion
literal='$greeting is not expanded'
echo "$literal"

# Double quotes allow expansion
expanded="$greeting is expanded"
echo "$expanded"

Variable Scope: Local, Global, and Export

By default, variables in Zsh are global within the script once assigned. To restrict a variable to the current function, use the local keyword. To make a variable available to child processes, use export (or typeset -x).

#!/usr/bin/env zsh

global_var="I am global"

function demo_scope() {
    local local_var="I am local"
    echo "Inside function: $local_var"
    echo "Inside function: $global_var"
}

demo_scope
echo "Outside function: $global_var"
# The next line prints nothing because local_var is out of scope
echo "Outside function: $local_var"

Exporting Variables

Exported variables become environment variables, visible to any subprocess spawned by the script. This is critical when calling other scripts or binaries that rely on configuration via the environment.

#!/usr/bin/env zsh

export API_KEY="secret123"
export DATABASE_URL="postgres://localhost/app"

# Child process can read these
python3 -c "import os; print(os.environ.get('API_KEY'))"

Arrays in Zsh

Zsh has excellent native support for arrays. Unlike Bash, Zsh arrays are 1-indexed by default, meaning the first element is at index 1. You can create indexed arrays using parentheses.

#!/usr/bin/env zsh

# Indexed array
fruits=(apple banana cherry)

# Access individual elements (1-indexed)
echo "First fruit: $fruits[1]"
echo "Second fruit: $fruits[2]"

# Get all elements
echo "All fruits: $fruits"
echo "All fruits (explicit): ${fruits[@]}"

# Array length
echo "Number of fruits: $#fruits"

# Append to array
fruits+=(date elderberry)
echo "Updated fruits: $fruits"

# Iterate
for fruit in $fruits; do
    echo "Fruit: $fruit"
done

Associative Arrays

Associative arrays (hash maps) store key-value pairs. Declare them with typeset -A or declare -A.

#!/usr/bin/env zsh

typeset -A config
config[host]="localhost"
config[port]="8080"
config[debug]="true"

# Access by key
echo "Host: $config[host]"
echo "Port: $config[port]"

# Iterate over keys
for key in ${(k)config}; do
    echo "$key = $config[$key]"
done

# Iterate over values
for value in ${(v)config}; do
    echo "Value: $value"
done

# Iterate over key-value pairs
for key value in ${(kv)config}; do
    echo "$key -> $value"
done

Parameter Expansion

Parameter expansion is one of the most powerful features of Zsh variables. It lets you transform, slice, substitute, and test values without calling external commands.

Default Values and Substitution

#!/usr/bin/env zsh

# Use default if unset or null
: ${USER:="guest"}
echo "User: $USER"

# Use alternative if set
echo "${DEBUG:+Debug mode is on}"

# Assign default permanently
: ${LOG_DIR:="/var/log/myapp"}
echo "Log directory: $LOG_DIR"

String Manipulation

#!/usr/bin/env zsh

filename="report_2024_01_15.csv"

# Length
echo "Length: $#filename"

# Substring (start, length)
echo "Substring: ${filename:0:6}"

# Remove shortest match from front
echo "${filename#report_}"

# Remove longest match from front
echo "${filename##*_}"

# Remove shortest match from back
echo "${filename%.*}"

# Remove longest match from back
echo "${filename%%_*}"

# Replace first occurrence
echo "${filename/_/-}"

# Replace all occurrences
echo "${filename//_/-}"

# Uppercase and lowercase
name="zsh scripting"
echo "Upper: ${name:u}"
echo "Lower: ${name:l}"

Array Slicing and Filtering

#!/usr/bin/env zsh

numbers=(10 20 30 40 50 60)

# Slice from index 2, length 3
echo "Slice: ${numbers[2,4]}"

# Get elements matching pattern
files=(a.txt b.log c.txt d.log)
echo "Txt files: ${files[(r)*.txt]}"

# Filter with glob
txt_files=(${files:#*.log})
echo "Non-log files: $txt_files"

Arithmetic with Variables

Zsh supports arithmetic evaluation using (()) or $(( )). Inside arithmetic context, you do not need the dollar sign for variables.

#!/usr/bin/env zsh

x=10
y=3

# Arithmetic expansion
sum=$((x + y))
difference=$((x - y))
product=$((x * y))
quotient=$((x / y))
remainder=$((x % y))

echo "Sum: $sum"
echo "Difference: $difference"
echo "Product: $product"
echo "Quotient: $quotient"
echo "Remainder: $remainder"

# Increment and decrement
((x++))
echo "After increment: $x"
((x--))
echo "After decrement: $x"

# Comparison returns 1 (true) or 0 (false)
if (( x > y )); then
    echo "$x is greater than $y"
fi

Special Variables

Zsh provides several built-in special variables that give you access to script metadata and runtime information.

#!/usr/bin/env zsh

echo "Script name: $0"
echo "Argument count: $#"
echo "All arguments: $@"
echo "First argument: $1"
echo "Last command exit status: $?"
echo "Current PID: $$"

# Loop through all arguments
for arg in "$@"; do
    echo "Argument: $arg"
done

Reading User Input

The read builtin lets you capture user input into variables. Zsh's read supports prompts and multiple variables in a single call.

#!/usr/bin/env zsh

# Basic input
read "reply?Enter your name: "
echo "Hello, $reply"

# Multiple variables
echo "Enter first and last name:"
read first last
echo "First: $first, Last: $last"

# Read into array
read -A "words?Enter several words: "
echo "You entered $#words words"
echo "Words: $words"

Capturing Command Output

You can store the output of a command in a variable using command substitution with $( ) or backticks. The $( ) form is preferred because it nests cleanly.

#!/usr/bin/env zsh

# Capture single-line output
current_date=$(date +%Y-%m-%d)
echo "Today: $current_date"

# Capture multi-line output into array
files=($(ls -1 /tmp))
echo "Found $#files files in /tmp"

# Capture with pipes
word_count=$(echo "count these words" | wc -w)
echo "Word count: $word_count"

# Nested substitution
echo "Kernel: $(uname -s) on $(uname -m)"

Typed Variables with typeset

The typeset builtin (also available as declare) lets you add attributes to variables, such as making them read-only, integer-only, or uppercase.

#!/usr/bin/env zsh

# Integer variable
typeset -i counter=5
counter=counter+10
echo "Counter: $counter"

# Read-only variable
typeset -r PI=3.14159
echo "Pi: $PI"
# PI=3 would cause an error: read-only variable

# Uppercase conversion on assignment
typeset -U upper_name
upper_name="hello"
echo "Upper name: $upper_name"

# Left-justify with width
typeset -L10 left_var="abc"
echo "[$left_var]"

# Right-justify with width
typeset -R10 right_var="abc"
echo "[$right_var]"

Best Practices

Following consistent conventions makes your Zsh scripts easier to read, debug, and maintain. Here are the most important best practices when working with variables.

Putting It All Together

The following example script combines many of the concepts covered in this guide: scalar variables, arrays, associative arrays, parameter expansion, arithmetic, functions with local scope, and user input.

#!/usr/bin/env zsh
set -u

# Configuration using associative array
typeset -A config
config[app_name]="BackupTool"
config[max_backups]=5
config[dest_dir]="/tmp/backups"

# Indexed array of source directories
sources=("$HOME/Documents" "$HOME/Projects")

# Function with local variables
function create_backup() {
    local source_dir="$1"
    local dest_dir="$2"
    local timestamp=$(date +%Y%m%d_%H%M%S)
    local archive_name="${source_dir:t}_${timestamp}.tar.gz"

    tar -czf "${dest_dir}/${archive_name}" -C "${source_dir:h}" "${source_dir:t}" 2>/dev/null

    if (( $? == 0 )); then
        echo "[$config[app_name]] Created: $archive_name"
        return 0
    else
        echo "[$config[app_name]] Failed to back up: $source_dir"
        return 1
    fi
}

# Main logic
echo "Starting ${config[app_name]:u}..."
echo "Destination: $config[dest_dir]"
echo "Sources: $#sources directories"

mkdir -p "$config[dest_dir]"

for src in $sources; do
    if [[ -d "$src" ]]; then
        create_backup "$src" "$config[dest_dir]"
    else
        echo "Warning: $src does not exist, skipping"
    fi
done

# Cleanup old backups beyond max_backups
existing=("${dest_dir}"/*.tar.gz(N))
if (( $#existing > config[max_backups] )); then
    echo "Cleaning up old backups..."
    for old_file in ${existing[1,$(( $#existing - config[max_backups] ))]}; do
        rm -f "$old_file"
        echo "Removed: ${old_file:t}"
    done
fi

echo "Done. Total backups retained: $(ls -1 ${config[dest_dir]}/*.tar.gz 2>/dev/null | wc -l)"

Conclusion

Variables are the backbone of every Zsh script, and mastering them unlocks the full power of the shell. In this guide you learned how to declare and assign scalar values, work with indexed and associative arrays, control scope with local and export, manipulate strings and arrays through parameter expansion, perform arithmetic, leverage special built-in variables, capture user input and command output, and apply typing with typeset. By combining these techniques and following the best practices outlined above, you can write Zsh scripts that are safe, readable, and maintainable. The best way to internalize these concepts is to build small utilities of your own, so pick a repetitive task you do often and automate it with a well-structured Zsh script today.

— Ad —

Google AdSense will appear here after approval

← Back to all articles