Overview

Bash scripts automate repetitive tasks on Linux and macOS. This tutorial walks through the building blocks, from variables and conditionals to functions and safe error handling, with a complete example at the end.

Your First Script

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

echo "Hello, world!"
chmod +x hello.sh
./hello.sh

set -euo pipefail is a safety line worth putting at the top of every script: -e exits on error, -u treats unset variables as errors, and pipefail makes a pipeline fail if any stage fails.

Variables

name="Alice"
count=3

echo "$name has $count messages"
echo "${name}s"          # braces disambiguate the variable name

readonly MAX=100
export PATH="$HOME/bin:$PATH"

Do not put spaces around = when assigning.

Command Substitution and Arithmetic

today=$(date +%F)
files=$(ls -1 | wc -l)

sum=$((3 + 4))
((count++))

Arguments and Exit Codes

VariableMeaning
$0Script name
$1, $2...Positional arguments
$#Number of arguments
$@All arguments as separate words
$?Exit status of the last command
$$Current process ID
if [[ $# -lt 2 ]]; then
  echo "Usage: $0 source destination" >&2
  exit 1
fi

Conditionals

if [[ -f "$file" ]]; then
  echo "File exists"
elif [[ -d "$file" ]]; then
  echo "It is a directory"
else
  echo "Not found"
fi
TestMeaning
-fRegular file exists
-dDirectory exists
-zString is empty
-nString is non-empty
-eq, -neNumeric equal / not equal
==, !=String equal / not equal

Loops

# Iterate over a list
for fruit in apple banana cherry; do
  echo "$fruit"
done

# Iterate over files
for file in *.log; do
  gzip "$file"
done

# C-style loop
for ((i = 1; i <= 5; i++)); do
  echo "Line $i"
done

# While loop
while read -r line; do
  echo "$line"
done < input.txt

Functions

log() {
  local level="$1"
  shift
  echo "[$(date +%T)] [$level] $*" >&2
}

log INFO "Starting backup"
log ERROR "Disk full"

Use local to keep variables scoped to the function. Return values with echo and capture them via command substitution.

Error Handling

set -euo pipefail

cleanup() {
  rm -f "$tmpfile"
}
trap cleanup EXIT

tmpfile=$(mktemp)
echo "working..." > "$tmpfile"

The trap ... EXIT pattern guarantees cleanup even if the script fails.

Complete Example: Log Rotation

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

LOG_DIR="${1:-/var/log/myapp}"
KEEP_DAYS="${2:-7}"

if [[ ! -d "$LOG_DIR" ]]; then
  echo "Directory not found: $LOG_DIR" >&2
  exit 1
fi

archived=0
while IFS= read -r -d '' file; do
  gzip "$file"
  ((archived++))
done < <(find "$LOG_DIR" -type f -name "*.log" -mtime +"$KEEP_DAYS" -print0)

find "$LOG_DIR" -type f -name "*.gz" -mtime +$((KEEP_DAYS * 4)) -delete

echo "Archived $archived file(s), removed old archives."

Best Practices

  • Always start with #!/usr/bin/env bash and set -euo pipefail.
  • Quote variables: "$var", not $var.
  • Use [[ ]] instead of [ ] for conditionals.
  • Prefer $(...) over backticks.
  • Run scripts through ShellCheck before committing.