Shell scripting tutorial: write safe bash and POSIX sh scripts

Updated November 2026

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

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

VariableMeaning
$0The 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
TestTrue when
-e filefile exists
-f file / -d diris a regular file / a directory
-r, -w, -xreadable, writable, executable
-s fileexists and is not empty
-z "$s" / -n "$s"string is empty / not empty
"$a" = "$b"strings are equal
$x -eq 5, -ne, -lt, -gtnumeric 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

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.