Back to Blog

Zsh Shell Tutorial 11: Functions — Arguments, Return Values & Scope

Sandy LaneSandy Lane

Video: Zsh Shell Tutorial 11: Functions — Arguments, Return Values & Scope 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 11: Functions — Arguments, Return Values, Scope

name() { ... } defines a function. Arguments via $1, $2, $@, $# (just like script args). return N for exit status (0-255). To return data, echo it and capture with $(...). local var for function-local variables.

Functions let you split a script into reusable, named pieces. Same syntax for all shells.

Defining a function

Two forms:

# Shorthand (POSIX-compatible)
greet() {
  echo "Hello, World!"
}

# function keyword (Zsh/Bash extension)
function farewell {
  echo "Goodbye, World!"
}

# Both can also use both:
function greet() { ... }

Use the shorthand name() { ... }. It works in every shell.

Calling functions

greet
# Hello, World!

farewell
# Goodbye, World!

No parentheses at the call site — just the function name. Arguments follow:

greet "Alice"

Arguments work like scripts

Inside a function, $1 $2 ... $@ $# reference the function's arguments, not the script's:

greet_user() {
  echo "Hello, $1! Welcome to $2."
}

greet_user "Alice" "Zsh Land"
# Hello, Alice! Welcome to Zsh Land.

This is a key distinction: at the function's top level, the $N are its arguments, not the script's. At the script level (outside functions), they're the script's command-line arguments.

Show all arguments

show_args() {
  echo "Total: $#"
  echo "All: $@"
  for arg in "$@"; do
    echo "  - $arg"
  done
}

show_args apple banana cherry
# Total: 3
# All: apple banana cherry
#   - apple
#   - banana
#   - cherry

"$@" (quoted) preserves argument boundaries — same as in scripts.

Return values: just an exit status

is_even() {
  if (( $1 % 2 == 0 )); then
    return 0
  else
    return 1
  fi
}

is_even 4
echo $?    # 0 — success (true)

is_even 7
echo $?    # 1 — failure (false)

return N exits the function with status N (0-255). 0 means success, non-zero means failure. Same convention as scripts.

Without an explicit return, the function returns the exit status of the last command that ran.

Use as conditions

Because functions return exit status, they work directly in if:

is_even() {
  (( $1 % 2 == 0 ))
}

if is_even 4; then
  echo "even"
fi

The (( )) expression returns 0 if true. The function inherits that. Cleaner than the explicit return 0/1.

"Returning" data via echo

Shell functions can't return strings or arrays directly. Print to stdout, capture with $(...):

get_greeting() {
  echo "Hello, $1!"
}

message=$(get_greeting "Bob")
echo "Captured: $message"
# Captured: Hello, Bob!

This is how every shell function passes data back. printf works too:

get_size() {
  printf "%dM" $(( ($(stat -f%z "$1") + 1024*1024 - 1) / (1024*1024) ))
}

size=$(get_size /tmp/file.bin)

Scope: variables are global by default

color="blue"

overwrite_color() {
  color="green"      # modifies global
}

overwrite_color
echo $color    # green — global was changed

By default, functions modify their enclosing scope. Watch out — easy to clobber.

local: function-only variables

color="blue"

change_color() {
  local color="red"     # function-local; doesn't affect outer
  echo "Inside: $color"
}

echo "Before: $color"   # blue
change_color            # Inside: red
echo "After: $color"    # blue (unchanged)

local declares a variable that exists only within the function. Always use local for function variables — prevents accidental clobbering.

In Zsh, you can also use typeset (same effect):

my_func() {
  typeset count=0
  ...
}

A library of functions

Group related functions in a script you source:

# lib.sh
log_info() {
  echo "[INFO] $*" >&2
}

log_error() {
  echo "[ERROR] $*" >&2
}

require_command() {
  command -v "$1" >/dev/null || {
    log_error "missing required command: $1"
    return 1
  }
}
# main.sh
source ./lib.sh

require_command git || exit 1
log_info "Starting backup..."

source runs the file in the current shell, so functions become available.

Recursion

Yes, shell functions can recurse:

factorial() {
  local n=$1
  if (( n <= 1 )); then
    echo 1
    return
  fi
  echo $(( n * $(factorial $((n - 1))) ))
}

factorial 5
# 120

But it's slow. Each recursive call forks a subshell. For real recursion, use a real language.

Default values for parameters

Use ${1:-default}:

greet() {
  local name="${1:-World}"
  echo "Hello, $name!"
}

greet         # Hello, World!
greet Alice   # Hello, Alice!

Validating arguments

backup() {
  if [[ $# -lt 1 ]]; then
    echo "Usage: backup <path>" >&2
    return 1
  fi
  local source="$1"
  local dest="${2:-/tmp/backup}"

  if [[ ! -e "$source" ]]; then
    echo "Error: $source doesn't exist" >&2
    return 1
  fi

  cp -a "$source" "$dest"
}

Validate inside the function; return non-zero on failure.

Anonymous functions (Zsh)

Zsh has anonymous functions for one-shot blocks:

() {
  local x=10
  local y=20
  echo $((x + y))
} 30 40    # called immediately with arguments

# 30  (because $1 = 30, $2 = 40 — not x and y)

Rarely useful. Mentioned for completeness.

A worked example

#!/usr/bin/env zsh
set -euo pipefail

# Library functions
log() {
  echo "[$(date +%H:%M:%S)] $*" >&2
}

require_command() {
  command -v "$1" >/dev/null || {
    log "ERROR: missing $1"
    return 1
  }
}

count_files() {
  local dir="$1"
  local pattern="${2:-*}"
  find "$dir" -type f -name "$pattern" | wc -l
}

# Main
main() {
  require_command find || exit 1

  local target="${1:-.}"
  log "Counting files in $target..."

  local total=$(count_files "$target")
  local logs=$(count_files "$target" "*.log")

  echo "Total files: $total"
  echo "Log files: $logs"
}

main "$@"

Note main "$@" at the bottom — forward script args to a main function. Common pattern for organizing larger scripts.

Common stumbles

Forgetting local. Function pollutes globals. Always local for function vars.

Returning strings via return. return "hello" errors — return only takes integers 0-255. Use echo and $(...).

Calling with parens. greet("Alice") is wrong. Functions are called like commands: greet "Alice".

(( count++ )) returning non-zero. When count was 0 (post-increment evaluates to 0). With set -e, exits the script. Use count=$((count + 1)) or ((count++)) || true.

Variable assignment inside if test. if [[ x=$(cmd) ]]; then — confusing. Assign first, test after.

Recursion is slow. Each call forks. For deep recursion, use a real language.

Function and command name collision. Defining cd() overrides the builtin. To call the original: builtin cd .... Avoid naming functions after builtins unless you're intentionally wrapping.

Forward declaration. Functions must be defined before use. If main() calls helper(), define helper first or use main "$@" at the very end of the file.

What's next

Lesson 12: arrays. Indexed and associative arrays.

Recap

name() { ... } defines a function. Arguments via $1 $2 ... $@ $# (shadows script args inside). return N for exit status (0-255). Pass data back via echo, capture with $(func). local var for function-local variables — always use it. Group helpers in a sourced lib. Validate arguments inside functions; return non-zero on failure. Forward script args to a main function with main "$@".

Next lesson: arrays.

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.