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
| Variable | Meaning |
|---|---|
$0 | Script 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
| Test | Meaning |
|---|---|
-f | Regular file exists |
-d | Directory exists |
-z | String is empty |
-n | String is non-empty |
-eq, -ne | Numeric 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 bashandset -euo pipefail. - Quote variables:
"$var", not$var. - Use
[[ ]]instead of[ ]for conditionals. - Prefer
$(...)over backticks. - Run scripts through ShellCheck before committing.
