Skip to content
ALB Wiki

Shell Scripting

This is an old revision of this page — revision 3 (2026-10-25 15:01:32 UTC). It may differ from the current revision.

A shell script is a program — the same grammar from chapter 9, saved and executable. This chapter turns the grammar into software: the shebang contract, arguments, control flow, functions, the safety flags, and the idioms real scripts are built from — all in portable POSIX sh, with bash's extensions marked.

The skeleton — every script's preamble

#!/bin/sh
# what this script does — one honest comment line
set -eu                      # -e die on error · -u die on unset vars
IFS='
'                        # optional: strict word-splitting hygiene

#!/bin/sh — the shebang — makes it a program; the kernel reads #! and hands the file to the named interpreter. chmod +x and it's runnable. The set flags are the professional preamble: -e stops at the first failure (no zombie scripts continuing after a cd failed), -u catches typo'd variables, -o pipefail (bash) makes pipelines propagate inner failures. #!/usr/bin/env bash for bash scripts specifically.

Arguments — the script's input

$0          # the script's own name
$1 $2 ...   # positional args
$#          # count
$@          # all args as separate words ("$@" — always quoted)
$*          # all args as one word
shift       # drop $1, everything moves down
if [ "$#" -lt 1 ]; then
    echo "usage: $0 <file>" >&2
    exit 1
fi
for f in "$@"; do ...; done      # "$@" iterates args safely

Variables — scope and habits

var=value              # NO SPACES around =
echo "$var"            # always quoted
readonly VERSION=1.0   # constants
local x                # function-local — bash; POSIX uses distinct names
export PATH="$PATH:/x" # into the environment
cmd=$(command)         # capture output — the universal idiom

Control flow — the complete set

if [ -f "$file" ]; then
    echo exists
elif [ -d "$file" ]; then
    echo dir
else
    echo missing
fi

case "$1" in
    start)   daemon_start ;;
    stop)    daemon_stop ;;
    -h|--help) usage ;;
    *)       echo "unknown: $1"; exit 1 ;;
esac

for f in *.txt; do            # glob-driven
    process "$f"
done
for i in 1 2 3; do echo "$i"; done
for i in $(seq 1 10); do ...; done       # numeric via seq

while IFS= read -r line; do   # the file-reading idiom — THE way to read lines
    printf '%s\n' "$line"
done < input.txt

until [ -f ready ]; do sleep 1; done    # until-loop

The while IFS= read -r line loop is the canonical file iterator — read splits on IFS, -r keeps backslashes, the IFS= prefix preserves whitespace. Never for line in $(cat file) — it word-splits.

Functions

die() { echo "error: $*" >&2; exit 1; }
usage() { cat <<EOF
usage: $0 <args>
EOF
}

backup() {
    local src="$1" dst="$2"     # bash 'local'
    cp -a "$src" "$dst" || die "backup failed: $src"
}

backup a/ b/ || exit 1          # functions return exit codes like commands

The safety machinery

set -eu                    # the preamble
set -x                     # trace — the debugging mode
trap 'cleanup' EXIT        # guaranteed cleanup — THE pattern
trap 'echo "line $LINENO failed"' ERR   # bash ERR trap
trap '' INT                # ignore signals
tmp=$(mktemp -d)           # safe tempdirs — always mktemp, never /tmp/foo.$$
trap 'rm -rf "$tmp"' EXIT

trap ... EXIT is the idiom that separates careful scripts from sloppy ones — temp files, lock files, mounted things — cleaned even on early failure.

printf over echo

printf '%s\n' "$var"       # portable, predictable
printf '%-10s %5d\n' name 42   # formatted columns
echo "$var"                # fine for simple; printf for format control

echo has portability landmines (-e, -n, backslashes); printf has none — use it for anything beyond a plain string.

The idioms that earn their keep

# arg-count check
[ "$#" -ge 1 ] || die "need a file"

# default value
name=${1:-default}

# required command
command -v gcc >/dev/null || die "gcc missing"

# quiet a command
cmd >/dev/null 2>&1

# the lock — prevent double-run
lock=/tmp/myapp.lock
exec 200>"$lock"; flock -n 200 || exit 0

# config override
: "${CONF:=/etc/myapp.conf}"   # bash/POSIX default-assign

# dry-run flag
[ "$DRY" = 1 ] && echo "would rm $f" || rm "$f"

# the pipe-safe read
cat file | while read -r l; do x="$l"; done   # subshell — x dies outside!
while read -r l; do x="$l"; done < file        # same shell — x survives

getopts — parsing flags

usage() { echo "usage: $0 [-v] [-o out] files..."; }
verbose=0; out=
while getopts "vo:h" opt; do
    case "$opt" in
        v) verbose=1 ;;
        o) out="$OPTARG" ;;
        h) usage; exit 0 ;;
        *) usage; exit 1 ;;
    esac
done
shift $((OPTIND - 1))     # remaining args in $@

getopts is POSIX — the -v, -o arg grammar handled properly. bash's getopt (no s) and manual while/case on $1 handle --long-flags where getopts doesn't.

The safe-script template — the professional skeleton

#!/bin/sh
# what this does — one honest line
set -eu                          # die on error, unset vars
umask 022
tmp=$(mktemp -d)                 # a private tempdir
trap 'rm -rf "$tmp"' EXIT        # guaranteed cleanup
trap 'echo "fail at line $LINENO" >&2' ERR   # bash only

die() { printf 'error: %s\n' "$*" >&2; exit 1; }
require() { command -v "$1" >/dev/null 2>&1 || die "$1 required"; }

require tar
require awk

main() {
    [ "$#" -ge 1 ] || die "need an argument"
    for f in "$@"; do
        [ -f "$f" ] || die "not a file: $f"
        process "$f"
    done
}
main "$@"

The pieces: set -eu, mktemp -d + trap, die(), require() for tool checks, main "$@" as the entry — the shape every serious script takes.

The signal traps — the shutdown machinery

trap 'cleanup' EXIT     # runs on any exit — even errors — THE idiom
trap 'kill $pid' TERM   # graceful shutdown
trap '' INT             # ignore Ctrl-C — the don't-interrupt-us zone
trap 'echo got sigusr1' USR1
trap - INT              # reset to default

The debugging toolkit

sh -x script.sh          # trace — THE debugger
sh -n script.sh          # syntax-only check
bash -v script.sh        # print lines as read
set -x ... set +x        # trace a region
PS4='+ $LINENO: ' sh -x script.sh   # line-numbered trace
shellcheck script.sh     # the linter — the real debugging tool

shellcheck catches the quoting bugs, the for x in $(ls), the unguarded rm — run it on every script.

The idioms library

# default value
: "${VAR:=default}"
# required var
: "${VAR:?must set VAR}"
# command exists
command -v cmd >/dev/null || die "missing cmd"
# retry n times
for i in 1 2 3; do cmd && break || sleep 1; done
# run-as-user
sudo -u svcacct script.sh
# the lock — only one instance
exec 9>/tmp/myapp.lock; flock -n 9 || exit 0
# atomic write
tmp=$(mktemp) && write > "$tmp" && mv "$tmp" target
# the here-doc to file
cat > file <<'EOF'
content — quoted EOF = literal, unquoted = expands
EOF
# parallel jobs with wait
for x in a b c; do work "$x" & done; wait
# read config key=val pairs
while IFS='=' read -r k v; do case "$k" in name) NAME="$v";; esac; done < cfg

POSIX vs bash — what you gain and lose

# bash powers worth the shebang
arr=(a b c); echo "${arr[1]}"           # arrays
[[ "$s" =~ ^[0-9]+$ ]]                  # regex test
${var/pat/rep}                          # substitution
(( i++ ))                               # arithmetic syntax
select opt in a b; do ...; done         # menus
declare -A map; map[k]=v                # associative arrays

Write POSIX unless you need the extension — then say #!/bin/bash plainly and use it.

Debugging — the honest workflow

sh -x script.sh            # trace every step — the debugger
bash -n script.sh          # syntax-check without running
set -x; ...; set +x        # trace a region
echo "DEBUG: var=$var" >&2 # printf-style probes to stderr

set -x plus trap plus >&2 diagnostics covers most script debugging without a real debugger.

Where scripts go wrong

SymptomCause
var=x: command not foundSpaces around = — var = x isn't assignment
Loop var empty outside the loop| while = subshell — < file instead
[ $x = 1 ] errors on empty xUnquoted — [ "$x" = 1 ]
Script says "file not found"CRLF line endings — the shebang line has \r
Works in bash, dies in sh#!/bin/sh script with bashisms
Temp file collisionUsed a fixed name — mktemp
Script keeps going after failureMissing set -e
read ate the pipefor+$(cat) instead of while-read

Exercises

  1. Write backup.sh — arg check, cp -a, die on failure, usage text.
  2. The while-read vs for-in-cat difference — prove the subshell loss.
  3. trap a temp dir's cleanup — exit early mid-script, verify it still cleaned.
  4. sh -x a script you wrote — read the trace.
  5. Port a bash script ([[, arrays) to strict POSIX — what's actually needed?

See also

Categories: Handbook · Linux · Linux Handbook · Scripting
This page was last edited 2026-10-25 15:01:32 UTC by ALB Team. What links here