Shell scripting tutorial: write safe bash and POSIX sh scripts
A shell script is a text file full of commands you would otherwise type. Scripts save time, document how a job is done and make it repeatable. This tutorial covers what you need to write scripts that keep working when file names contain spaces or a command fails.
Your first script
#!/bin/bash
# hello.sh - print a greeting
echo "Hello, $USER. Today is $(date +%A)."
The first line, the shebang, tells the system which interpreter runs the file. Make the script executable and run it:
chmod +x hello.sh
./hello.sh
Use #!/bin/bash when you use bash features (arrays, [[ ]]), or #!/bin/sh for portable POSIX scripts. On Debian and Ubuntu /bin/sh is dash, which has no bash extensions, so a script that works with bash may fail with sh.
Start every script safely
#!/bin/bash
set -euo pipefail
-e: stop when a command fails, instead of carrying on with bad data.-u: treat an unset variable as an error (catches typos such as$FIEL).-o pipefail: a pipeline fails if any command in it fails, not just the last one.
These are not perfect (-e is ignored inside if conditions, for example), but they turn many silent failures into visible ones.
Variables and quoting
name="backup" # no spaces around =
today=$(date +%F) # command substitution: 2026-11-02
file="$name-$today.tar.gz"
echo "Creating $file"
echo "Braces separate names: ${name}_old"
Always put variables in double quotes: "$file", not $file. Without quotes, the shell splits the value at spaces and expands wildcards, so a file called my report.txt becomes two arguments. Single quotes keep everything literally: '$HOME' prints a dollar sign and the word HOME.
f="my report.txt"
rm $f # WRONG: tries to remove "my" and "report.txt"
rm "$f" # right
Arguments
| Variable | Meaning |
|---|---|
$0 | The script's name |
$1, $2 … | The first, second … argument |
$# | Number of arguments |
"$@" | All arguments, each kept separate (use this one) |
$? | Exit status of the last command |
$$ | Process ID of the script |
#!/bin/bash
set -euo pipefail
if [ $# -lt 1 ]; then
echo "usage: $0 directory..." >&2
exit 2
fi
for dir in "$@"; do
echo "$dir uses $(du -sh "$dir" | cut -f1)"
done
Default values: ${1:-/var/log} uses the first argument, or /var/log if none was given.
Tests and if
if [ -f /etc/os-release ]; then
. /etc/os-release
echo "Running on $PRETTY_NAME"
elif [ -d /etc/sysconfig ]; then
echo "Looks like an older Red Hat system"
else
echo "Unknown system" >&2
fi
| Test | True when |
|---|---|
-e file | file exists |
-f file / -d dir | is a regular file / a directory |
-r, -w, -x | readable, writable, executable |
-s file | exists and is not empty |
-z "$s" / -n "$s" | string is empty / not empty |
"$a" = "$b" | strings are equal |
$x -eq 5, -ne, -lt, -gt | numeric comparisons |
Any command can be a test: if grep -q error app.log; then ... runs the branch when grep finds a match. In bash, [[ ... ]] is safer than [ ... ] and supports pattern matching: [[ $file == *.log ]].
Loops
# over a list or a wildcard
for f in /var/log/*.log; do
echo "$f: $(wc -l < "$f") lines"
done
# over numbers
for i in {1..5}; do echo "attempt $i"; done
# while a condition holds
count=0
while [ "$count" -lt 3 ]; do
count=$((count + 1))
done
# read a file line by line (keeps spaces and backslashes)
while IFS= read -r line; do
echo "user: $line"
done < users.txt
Do not loop over the output of ls (for f in $(ls)): it breaks on spaces. Use a wildcard, or find with -exec or -print0 | xargs -0 (see xargs).
case
case "$1" in
start) echo "starting" ;;
stop) echo "stopping" ;;
restart) "$0" stop; "$0" start ;;
*) echo "usage: $0 {start|stop|restart}" >&2; exit 2 ;;
esac
Functions
log() {
printf '%s %s\n' "$(date '+%F %T')" "$*" >&2
}
die() {
log "ERROR: $*"
exit 1
}
backup_dir() {
local src=$1 dest=$2 # local keeps variables inside the function
tar -czf "$dest" -C "$(dirname "$src")" "$(basename "$src")" || die "tar failed for $src"
log "saved $src to $dest"
}
backup_dir /etc "/backup/etc-$(date +%F).tar.gz"
Exit codes
Every command returns a number: 0 means success, anything else failure. Your scripts should do the same with exit 0 or exit 1, so other scripts and cron can tell whether they worked. Combine commands with && (run the next only if this one succeeded) and || (run the next only if it failed):
mkdir -p /backup && cd /backup || exit 1
Cleaning up with trap
trap runs a command when the script exits, even after an error or Ctrl+C. Use it to remove temporary files:
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
curl -fsSL https://example.com/data.csv -o "$tmp/data.csv"
sort "$tmp/data.csv" | uniq > result.csv
Always create temporary files with mktemp, never with a fixed name in /tmp: another user could create that name first.
Reading input and doing arithmetic
read -r -p "Delete old logs? [y/N] " answer
[ "$answer" = "y" ] || exit 0
total=$(( 1024 * 1024 ))
echo "$(( total / 1000 )) kB"
The shell only does whole-number arithmetic. For decimals use awk or bc: echo "scale=2; 10/3" | bc.
A complete example
This script removes files more than N full days old from a directory (find -mtime +14 counts whole days, so it means 15 days or more), logs what it did and refuses to run on /:
#!/bin/bash
# clean-old.sh - delete files older than N days
set -euo pipefail
usage() { echo "usage: $0 directory days" >&2; exit 2; }
[ $# -eq 2 ] || usage
dir=$1 days=$2
[[ $days =~ ^[0-9]+$ ]] || usage
[ -d "$dir" ] || { echo "$dir is not a directory" >&2; exit 1; }
[ "$(realpath "$dir")" != "/" ] || { echo "refusing to clean /" >&2; exit 1; }
count=$(find "$dir" -type f -mtime +"$days" -print | wc -l)
find "$dir" -type f -mtime +"$days" -delete
logger -t clean-old "removed $count files older than $days days from $dir"
echo "removed $count files"
Run it daily with crontab: 30 2 * * * /usr/local/bin/clean-old.sh /srv/uploads/tmp 14.
Debugging
bash -n script.shchecks the syntax without running it.bash -x script.shprints every command before it runs, with variables expanded. Addset -xandset +xaround one part of a script.- ShellCheck (
apt install shellcheck) finds quoting mistakes and common bugs. Run it on every script.
When not to use the shell
Shell is ideal for gluing commands together. When a script grows past a few hundred lines, needs complex data structures, or does careful text parsing, a language such as Python or Perl will be easier to get right. See programming on Unix.