Back to Blog

Zsh read, $1, getopts Explained - Shell Scripting Tutorial (Lesson 8)

Sandy LaneSandy Lane

Video: Zsh read, $1, getopts Explained - Shell Scripting Tutorial (Lesson 8) 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 8: Script Input — $1, read, getopts

Scripts get arguments via $1, $2, ... $@ is all args, $# is count, $0 is the script name. read for interactive prompts. getopts for short option flags -n alice -a 25.

A script that doesn't take input is a one-shot. Real tools accept arguments — names, paths, flags.

Positional arguments: $1 $2 $3 ...

#!/usr/bin/env zsh
echo "Script name: $0"
echo "First: $1"
echo "Second: $2"
echo "Third: $3"
echo "All: $@"
echo "Count: $#"
./show_args.sh hello world foo
# Script name: ./show_args.sh
# First: hello
# Second: world
# Third: foo
# All: hello world foo
# Count: 3

Special vars:

  • $0 — the script's name.
  • $1, $2, ... — positional arguments.
  • $@ — all positional arguments as separate words (when quoted: "$@").
  • $* — all positional arguments as one string.
  • $# — number of positional arguments.

$@ vs $*

args=("$@")    # array — preserves separations
echo $#         # count

The difference matters with quoting:

# Args: "hello world" "foo"

# "$@" → "hello world" "foo" (two args, preserved)
# "$*" → "hello world foo"   (one string, joined by IFS)

Always use "$@" (quoted) when forwarding all arguments to another command. It's the only form that preserves args correctly.

Defaults for missing args

NAME="${1:-World}"
AGE="${2:-25}"

echo "Hello, $NAME! Age: $AGE"
./greet.sh
# Hello, World! Age: 25

./greet.sh Alice 30
# Hello, Alice! Age: 30

${VAR:-default} returns default if VAR is empty or unset. Standard pattern for optional args.

Validating required args

if [[ -z "$1" ]]; then
  echo "Usage: $0 <name>" >&2
  exit 1
fi

NAME="$1"
echo "Hello, $NAME!"

-z tests "zero length." Refuse to run without the required argument; print usage to stderr; exit non-zero.

$$, $?, $!

A few more special variables:

echo "PID: $$"            # current shell's process ID
echo "Last exit: $?"      # exit status of last command
echo "Last bg PID: $!"    # PID of last background command

$? is huge. After every command, $? holds its exit code. Use it to check success:

some_command
if [[ $? -ne 0 ]]; then
  echo "It failed!" >&2
fi

Or directly with if:

if some_command; then
  echo "ok"
else
  echo "failed" >&2
fi

read: interactive input

echo "What's your name?"
read NAME
echo "Hi, $NAME!"

read reads a line from stdin, splits on whitespace by default, and assigns to the named variables.

read -p "Name: " NAME            # prompt inline
read -p "Age: " AGE
read -sp "Password: " PASS       # -s = silent (no echo)
echo                              # newline after silent input

read -t 5 -p "Hurry: " ANSWER    # -t timeout in seconds

Multiple variables:

read FIRST LAST <<< "Alice Smith"
echo "$FIRST $LAST"
# Alice Smith

Reading a file line by line

while IFS= read -r line; do
  echo "Line: $line"
done < input.txt

IFS= keeps leading/trailing whitespace. -r disables backslash interpretation. The combination is the canonical "read a file" pattern.

getopts: short flags (-n alice)

For tools with options like -n alice -a 25:

#!/usr/bin/env zsh
# profile.sh - Option parsing with getopts

name="Unknown"
age="N/A"

while getopts "n:a:h" opt; do
  case $opt in
    n) name=$OPTARG ;;
    a) age=$OPTARG ;;
    h)
      echo "Usage: $0 [-n name] [-a age] [-h]"
      exit 0
      ;;
    \?)
      echo "Invalid option: -$OPTARG" >&2
      exit 1
      ;;
  esac
done

echo "Name: $name"
echo "Age: $age"
./profile.sh -n Alice -a 25
# Name: Alice
# Age: 25

./profile.sh -h
# Usage: ./profile.sh [-n name] [-a age] [-h]

How getopts works:

  • getopts "n:a:h" opt — defines flags. n: and a: take values; h doesn't.
  • The current option ends up in $opt; the value (if any) in $OPTARG.
  • getopts returns 0 while there are options to parse, non-zero when done.

After parsing, shift past the options:

shift $((OPTIND - 1))
echo "Remaining args: $@"

$OPTIND is the index of the next argument to process — what's left after all flags.

Long options (--name)

getopts only supports short options. For long options (--name alice), you have two choices:

  1. Loop with case — manual parsing:
while [[ $# -gt 0 ]]; do
  case "$1" in
    --name) NAME="$2"; shift 2 ;;
    --age) AGE="$2"; shift 2 ;;
    --help) echo "Usage..."; exit 0 ;;
    *) echo "Unknown: $1" >&2; exit 1 ;;
  esac
done
  1. Use getopt (no s) — GNU extended parser. Available on Linux; needs install on macOS (brew install gnu-getopt).

For most scripts, the manual while/case loop is fine.

A complete script with all input methods

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

# Defaults
NAME="${USER}"
COUNT=1
VERBOSE=0

# Parse short options
while getopts "n:c:vh" opt; do
  case $opt in
    n) NAME="$OPTARG" ;;
    c) COUNT="$OPTARG" ;;
    v) VERBOSE=1 ;;
    h)
      echo "Usage: $0 [-n name] [-c count] [-v] [GREETING]"
      exit 0
      ;;
    \?) exit 1 ;;
  esac
done

shift $((OPTIND - 1))
GREETING="${1:-Hello}"

# Validate
if [[ ! "$COUNT" =~ ^[0-9]+$ ]]; then
  echo "Error: count must be a number" >&2
  exit 2
fi

# Run
for ((i = 1; i <= COUNT; i++)); do
  if (( VERBOSE )); then
    echo "[iteration $i] $GREETING, $NAME!"
  else
    echo "$GREETING, $NAME!"
  fi
done
./greet.sh -n Alice -c 3 Howdy
# Howdy, Alice! (x3)

./greet.sh -v -c 2
# [iteration 1] Hello, alice!
# [iteration 2] Hello, alice!

Short flags + positional args + defaults + validation. The standard skeleton.

Reading from stdin (piped input)

# script.sh
while IFS= read -r line; do
  echo "got: $line"
done
# Run with piped input
echo "hello\nworld" | ./script.sh
# got: hello
# got: world

# Or with file input
./script.sh < input.txt

For tools that should work both interactively and in pipelines.

Common stumbles

$1 == "" not catching missing args. [[ -z "$1" ]] is the right way; combined with set -u you may need [[ -z "${1:-}" ]].

Forgetting OPTARG. case $opt in n) name=$1 ;; is wrong — the value is in $OPTARG, not $1.

Not shifting after getopts. Subsequent $1 $2 still reference the options. shift $((OPTIND - 1)) consumes them.

Quoting "$@". Forward all args with "$@", not $@ or $*. Without quotes, args with spaces get split.

read with multiple vars and few values. read A B C <<< "one"A=one, B=, C= (no error).

Missing IFS= in read loops. while read line; do ... strips leading whitespace from each line. while IFS= read -r line preserves it.

Using getopt(1) (no s) on macOS. macOS ships the BSD version, which doesn't support long options. Either brew install gnu-getopt or use manual parsing.

What's next

Lesson 9: conditionals. if, [[ ]], file tests, case.

Recap

Positional args: $1 $2 ... $@ "$@" $# $0. Defaults via ${1:-default}. Required: check [[ -z "$1" ]]. read VAR for interactive input (-p prompt, -s silent, -t timeout). getopts "n:a:h" for short options; value in $OPTARG, shift $((OPTIND-1)) after. For long options, use a manual while/case loop. Always quote "$@" when forwarding.

Next lesson: conditionals.

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.