DaleSchool

Shell Scripting Basics

Beginner30min

Learning Objectives

  • Understand the basic structure of a shell script and run it
  • Use variables, conditionals, and loops in scripts
  • Handle arguments and exit status with $@, $#, $?
  • Write safe scripts with set -e, set -u

Working Code

Example 1: Your first script

cat > greet.sh << 'EOF'
#!/bin/bash
echo "Hello!"
echo "Today's date: $(date +%Y-%m-%d)"
echo "Current user: $USER"
EOF

Make it executable and run:

chmod +x greet.sh
./greet.sh

Output:

Hello!
Today's date: 2026-03-06
Current user: dale

$(command) is command substitution. The output of date +%Y-%m-%d gets inserted in place.

Example 2: Accepting arguments

cat > say_hello.sh << 'EOF'
#!/bin/bash
echo "Hello, $1!"
echo "Number of arguments: $#"
EOF

chmod +x say_hello.sh
./say_hello.sh Alice

Output:

Hello, Alice!
Number of arguments: 1

Special variables:

  • $0 — the script name itself
  • $1, $2... — arguments
  • $# — number of arguments
  • $@ — all arguments (as separate items)
  • $? — exit code of the last command (0=success, non-zero=failure)

Example 3: Validating arguments with conditionals

cat > backup.sh << 'EOF'
#!/bin/bash
set -e   # exit immediately on error
set -u   # error on undefined variables

# Check arguments
if [ "$#" -eq 0 ]; then
  echo "Usage: $0 <directory>"
  exit 1
fi

SOURCE="$1"
BACKUP="${SOURCE}-backup-$(date +%Y%m%d)"

if [ ! -d "$SOURCE" ]; then
  echo "Error: directory '$SOURCE' does not exist."
  exit 1
fi

cp -r "$SOURCE" "$BACKUP"
echo "Backup complete: $BACKUP"
EOF

chmod +x backup.sh
./backup.sh Documents

Output:

Backup complete: Documents-backup-20260306

Try It Yourself

Conditionals and Loops

Conditional structure:

if [ condition ]; then
  # when true
elif [ other_condition ]; then
  # second condition
else
  # when all false
fi

Common conditions:

| Condition | Meaning | | -------------- | --------------------- | | -f file | File exists | | -d directory | Directory exists | | -z "$var" | Variable is empty | | -n "$var" | Variable is not empty | | "$a" = "$b" | Strings are equal | | "$a" != "$b" | Strings are different | | $a -eq $b | Numbers are equal | | $a -gt $b | a is greater than b | | $a -lt $b | a is less than b | | ! condition | Negate condition |

Loops:

# Iterate over a list
for item in apple banana grape; do
  echo "Fruit: $item"
done

# Iterate over files
for file in *.txt; do
  echo "Processing: $file"
done

# Number range (using seq)
for i in $(seq 1 5); do
  echo "Number: $i"
done

# While loop
count=0
while [ $count -lt 3 ]; do
  echo "count: $count"
  count=$((count + 1))
done

Processing All Arguments with $@

cat > process_files.sh << 'EOF'
#!/bin/bash
if [ "$#" -eq 0 ]; then
  echo "Please specify files to process."
  exit 1
fi

echo "Processing $# file(s)."

for file in "$@"; do
  if [ -f "$file" ]; then
    echo "Processing: $file ($(wc -l < "$file") lines)"
  else
    echo "Skipping: $file (file not found)"
  fi
done
EOF

chmod +x process_files.sh
./process_files.sh file1.txt file2.txt file3.txt

"$@" passes all arguments as separate items. Safe even with filenames containing spaces.

Defining and Using Functions

cat > utils.sh << 'EOF'
#!/bin/bash

# Define a function
log() {
  echo "[$(date +%H:%M:%S)] $1"
}

# Check exit status
check_success() {
  if [ "$?" -eq 0 ]; then
    log "OK: $1"
  else
    log "FAIL: $1"
    exit 1
  fi
}

# Usage
log "Script started"
mkdir -p /tmp/test-dir
check_success "Directory created"
cp /tmp/test-dir /tmp/test-copy 2>/dev/null
check_success "Directory copied"
log "Done"
EOF

Functions also use $1, $2... for their arguments. Inside a function, $1 refers to the function argument, not the script argument.

Local Variables

calculate() {
  local num1="$1"   # local: only valid inside the function
  local num2="$2"
  local result=$((num1 + num2))
  echo $result
}

result=$(calculate 10 20)
echo "10 + 20 = $result"

Without local, variables inside functions become global, causing unintended side effects.

"Why?" — Core Script Structure

Shebang (#!/bin/bash)

The first line of a script. Specifies which interpreter to use.

#!/bin/bash    # run with bash
#!/bin/sh      # run with sh (more portable)
#!/usr/bin/env bash  # find bash in PATH (most portable)

Safe scripts with set options

#!/bin/bash
set -e   # exit on any command failure
set -u   # error on undefined variables
set -o pipefail  # fail pipe if any part fails

# Or in one line
set -euo pipefail

Adding these three lines at the top of every script catches unexpected errors early.

Exit Codes

# Success
exit 0

# Failure
exit 1

# Check the last command's exit code
ls /tmp
echo $?   # 0 (success)

ls /nonexistent
echo $?   # 1 (failure)

Exit code 0 means success, anything else means failure. CI/CD systems use this value to determine pass/fail.

Common Mistakes

Mistake 1: Running without execute permission

./script.sh
# zsh: permission denied: ./script.sh

# Fix
chmod +x script.sh
./script.sh

Mistake 2: Missing quotes around variables with spaces

# Dangerous: splits into two arguments if filename has spaces
FILE="my file.txt"
rm $FILE    # interpreted as rm "my" "file.txt"!

# Safe: always use double quotes
rm "$FILE"

Mistake 3: Ignoring errors without set -e

#!/bin/bash
# Without set -e, execution continues after failure
cp /nonexistent /destination   # fails
rm /destination                # runs anyway

# With set -e, stops at first failure
#!/bin/bash
set -e
cp /nonexistent /destination   # fails -> script exits
rm /destination                # never runs

Mistake 4: Missing spaces in conditionals

# Wrong (no spaces)
if [$# -eq 0]; then

# Correct (spaces after [ and before ])
if [ $# -eq 0 ]; then

Deep Dive

Cleanup on error/exit with trap
#!/bin/bash
set -e

# Temp file path
TMPFILE=$(mktemp)

# Delete temp file on exit (normal, error, or Ctrl+C)
trap 'rm -f "$TMPFILE"' EXIT

# Print message on error
trap 'echo "Error on line $LINENO"' ERR

# Do work
echo "Working" > "$TMPFILE"
cat "$TMPFILE"
echo "Done"

trap registers cleanup code that runs on specific signals or events.

Debugging scripts
# Print every command as it executes (debug mode)
bash -x script.sh

# Add set -x inside the script
set -x
# ... section to debug ...
set +x

# Check syntax only (no execution)
bash -n script.sh

When unexpected behavior occurs, add set -x to trace exactly which commands run and how.

Using arrays
# Define an array
fruits=("apple" "banana" "grape")

# Print all
echo "${fruits[@]}"

# Access by index (starts at 0)
echo "${fruits[0]}"   # apple
echo "${fruits[1]}"   # banana

# Array length
echo "${#fruits[@]}"  # 3

# Loop over array
for fruit in "${fruits[@]}"; do
  echo "Fruit: $fruit"
done
  1. Write hello.sh that takes one argument and prints "Hello, [argument]!". If no argument is given, print usage and exit.
  2. Write a script that prints numbers 1 to 10, replacing multiples of 3 with "Fizz".
  3. Add set -euo pipefail to a script and try copying a nonexistent file. Observe what happens.
  4. Write a script using $@ that takes multiple filenames as arguments and prints the line count of each.

:::quiz{answer="C" explanation=""$@" passes each argument as a separate item, safely handling filenames with spaces."} Q1. Which variable correctly handles all arguments individually in a shell script? (Safe even with spaces in filenames)

  • A) $*
  • B) $1
  • C) "$@"
  • D) $# :::