Home/Linux Administration/Bash Scripting
📜

Level 13 of 24

Bash Scripting

Variables, loops, functions, and error handling — building real automation, not one-liners.

A one-off command is fine to type by hand once. Anything you'd do more than a couple of times — a health check, a cleanup task, a deployment step — belongs in a script: repeatable, reviewable, and far less error-prone than re-typing the same sequence from memory.

Prerequisites

  • • Level 2: Command Line

By the end of this level, you can

  • ✓ Write a bash script with variables, conditionals, and loops
  • ✓ Use functions and arguments to make a script reusable
  • ✓ Handle errors properly instead of letting a script fail silently

Variables, conditionals, and loops

The core building blocks every real script is made of. · 11 min

A bash script is just a text file of shell commands, made executable and run directly — starting with a "shebang" line (`#!/bin/bash`) that tells the system which interpreter to run it with. Variables are assigned with no spaces around `=` (`name="value"`) and referenced with `$name` or `${name}` (the braces version avoids ambiguity when the variable name is adjacent to other text, like `${name}_backup`). Unlike many languages, bash variables are untyped strings by default — arithmetic requires explicit syntax like `$((a + b))` or the `let` builtin.

Conditionals use `if`/`then`/`elif`/`else`/`fi`, commonly paired with the `[[ ... ]]` test construct (the modern, more forgiving alternative to the older single-bracket `[ ... ]`): `if [[ -f "$file" ]]; then` tests whether a file exists, `if [[ "$a" == "$b" ]]; then` tests string equality, `if [[ $count -gt 10 ]]; then` tests numeric comparison — note `-gt`/`-lt`/`-eq` for numbers versus `==`/`!=` for strings, a common source of subtle bugs when the wrong comparison operator is used for the data type.

Loops iterate: `for item in list; do ... done` walks a fixed list or the output of a command; `while [[ condition ]]; do ... done` continues as long as a condition holds, useful for polling or reading input line by line. A pattern you'll use constantly: `for file in *.log; do ... done` to process every matching file in a directory without hard-coding filenames.

CommandPurposeExample
chmod +x script.shMake a script executable—
./script.shRun an executable script in the current directory (the ./ is required — see Level 2)—
bash script.shRun a script explicitly with bash, regardless of its own permissions or shebang—

A minimal script combining all three

#!/bin/bash

for file in /var/log/*.log; do
  size=$(stat -c%s "$file")
  if [[ $size -gt 104857600 ]]; then
    echo "WARNING: $file is over 100MB ($size bytes)"
  fi
done

Common Mistakes

  • ⚠ Using `==` for numeric comparison instead of `-eq`, or `-eq` for string comparison instead of `==` — bash won't always error clearly, it may just silently produce the wrong result
  • ⚠ Forgetting to quote variables (`$file` instead of `"$file"`) — this breaks on filenames containing spaces, one of the single most common real bash bugs
  • ⚠ Forgetting `chmod +x` and then being confused why `./script.sh` gives "permission denied"

Takeaway: Quote your variables (`"$file"`, not `$file`) essentially everywhere — the single most common real-world bash bug is a script that works fine until it meets a filename with a space in it.

Functions, arguments, and proper error handling

Turning a script from a one-off sequence into something reusable and safe to run unattended. · 10 min

Functions group reusable logic under a name: `my_function() { ... }`, called later just as `my_function`. Arguments passed to a script are available as `$1`, `$2`, etc. (positional parameters), `$@` for all arguments as a list, and `$#` for the argument count — a script that validates it received the expected number of arguments before proceeding is meaningfully more robust than one that assumes and fails confusingly deep into its logic when an argument is missing.

Every command produces an exit status (`$?` immediately after it runs) — 0 conventionally means success, any non-zero value means some kind of failure, with the specific non-zero value sometimes meaningful (different failure reasons) depending on the command. `set -e` at the top of a script makes it exit immediately on any command's non-zero exit status, instead of the default bash behavior of plowing ahead regardless — genuinely important for scripts run unattended (via cron or a systemd timer) where silent partial failure can be far worse than an obvious, immediate stop.

`set -u` additionally makes referencing an undefined variable an error instead of silently substituting an empty string — this specifically catches the exact class of bug behind the classic `rm -rf $UNSET_VAR/*` disaster from Level 3, since an unset `$UNSET_VAR` under `set -u` would halt the script immediately with a clear error instead of silently expanding to `rm -rf /*`. Combining `set -euo pipefail` (the third flag makes a pipeline fail if any command in it fails, not just the last one) at the top of production scripts is a genuinely strong, low-effort default worth adopting as a habit.

CommandPurposeExample
set -euo pipefailExit on error, exit on unset variable, and fail a pipeline if any stage fails — a strong safety default—
echo $?Show the exit status of the last command run—
trapRun a command/function automatically on script exit or a specific signal — useful for cleanuptrap cleanup EXIT

A small, genuinely production-shaped script

#!/bin/bash
set -euo pipefail

log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}

check_disk_usage() {
  local threshold=$1
  local usage
  usage=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
  if [[ $usage -gt $threshold ]]; then
    log "WARNING: disk usage at ${usage}%, threshold is ${threshold}%"
    return 1
  fi
  log "Disk usage OK: ${usage}%"
}

check_disk_usage 80

Common Mistakes

  • ⚠ Writing a script with no error handling that's meant to run unattended via cron — a silent partial failure at 3am is far worse than a script that stops loudly and immediately
  • ⚠ Not validating `$#` (argument count) before using `$1`/`$2`, leading to confusing downstream errors when a script is called with the wrong number of arguments
🧪

Hands-On Lab

Write a disk-monitoring script

Objectives

  • ✓ Write a script that checks disk usage against a configurable threshold
  • ✓ Use a function, an argument, and proper error handling
  • ✓ Confirm it exits with the correct status code for both the healthy and warning cases

Instructions

  1. Create a script `disk-check.sh` starting with `#!/bin/bash` and `set -euo pipefail`.
  2. Write a function `check_disk_usage` that takes a threshold percentage as its argument, checks the root filesystem's usage with `df`, and prints a warning if usage exceeds the threshold.
  3. Have the function `return 1` (a non-zero exit status) when the threshold is exceeded, and `return 0` implicitly otherwise.
  4. Make the script accept the threshold as `$1` instead of hard-coding it, with a default value if no argument is given.
  5. Run it with a deliberately very low threshold (like 1) to confirm the warning path and non-zero exit status both work: `./disk-check.sh 1; echo "Exit code: $?"`.
Hints (1)
  • A default value for an unset positional argument can be set with `threshold=${1:-80}` — this uses 80 if $1 wasn't provided at all.

Quick Check

Why is `set -u` specifically useful for preventing accidental destructive commands?

Takeaway: `set -euo pipefail` at the top of every script you intend to actually run — especially unattended — is one of the highest-value, lowest-effort habits in this entire course.

Sponsor / Advertisement