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
- Write
hello.shthat takes one argument and prints "Hello, [argument]!". If no argument is given, print usage and exit. - Write a script that prints numbers 1 to 10, replacing multiples of 3 with "Fizz".
- Add
set -euo pipefailto a script and try copying a nonexistent file. Observe what happens. - 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)
$#:::