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.
$0— The name of the script being executed.$1, $2, ... $9— Positional parameters passed to the script or function.$@— All positional parameters as separate words.$*— All positional parameters as a single word.$#— The number of positional parameters.$?— The exit status of the last command.$$— The process ID of the current shell.$!— The process ID of the last background command.$_— The last argument of the previous command.$ERRNO— The error number of the last system call (Zsh-specific).
#!/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.
- Quote variable expansions — Even though Zsh does not word-split unquoted expansions by default, quoting protects against empty values and special characters, and makes your code portable to Bash.
- Use meaningful names — Choose descriptive names like
log_file_pathinstead ofxorlfp. Use lowercase for local variables and uppercase for exported environment variables. - Prefer
localin functions — Always declare variables aslocalinside functions to avoid polluting the global namespace and causing subtle bugs. - Initialize variables before use — Set default values with parameter expansion like
${var:-default}to prevent unexpected behavior when a variable is unset. - Use
set -ufor safety — Enable theunsetoption at the top of scripts so that referencing an undefined variable causes an error rather than silently expanding to nothing. - Use arrays for lists — Avoid cramming multiple values into a single string separated by spaces. Use indexed or associative arrays for cleaner, safer data handling.
- Avoid
eval— Dynamic variable names viaevalare dangerous and hard to debug. Prefer associative arrays when you need to map keys to values dynamically. - Comment non-obvious expansions — Parameter expansion syntax can be cryptic. Add a brief comment explaining what a complex expansion does.
- Validate input — Check that variables contain expected values before using them in commands, especially when they come from user input or external files.
- Use
typesetfor clarity — When a variable must be an integer or read-only, declare it explicitly so the intent is clear and the shell enforces it.
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.