Bash Scripting Part 2: Conditionals, Loops, and Functions
The first-script article ended with bash running commands in a straight line. Scripts earn their keep when the line bends: run this only if that succeeded, repeat this for every file, package that up as a reusable block. That is conditionals, loops, and functions, and together they are most of what separates a saved command from a real tool.
if: branching on exit status
The thing that makes bash conditionals click is realizing if does not test expressions, it runs commands. Every command exits with a status: 0 for success, anything else for failure. if branches on that:
if grep -q "ERROR" /var/log/app.log; then
echo "errors found"
else
echo "log is clean"
fi
No brackets in sight; grep’s own exit status drives the branch. The familiar brackets are themselves just a command (test) whose job is evaluating conditions and exiting accordingly:
if [[ -f /etc/myapp.conf ]]; then
echo "config exists"
fi
Because [[ is parsed as a command, the spaces around it are mandatory: [[-f is a command-not-found error, not a syntax variation.
The conditions you will use constantly: -f file (regular file exists), -d dir (directory exists), -z "$var" (string empty), -n "$var" (string nonempty), "$a" == "$b" (string equality), and arithmetic comparisons -eq -ne -lt -gt for numbers. Prefer [[ ]] over single [ ] in bash: it forgives unquoted variables, supports &&/|| inside, and adds pattern matching ([[ $file == *.log ]]).
Exit-status thinking also gives you the one-line forms:
mkdir -p /backup || exit 1 # bail if this fails
command -v jq >/dev/null && echo "jq available"
for: iterating a list
for f in /var/log/*.log; do
echo "processing $f"
gzip -k "$f"
done
The glob expands to a list, the loop walks it, and quoting "$f" everywhere keeps names with spaces intact. Never write for f in $(ls); it shatters on whitespace and there is always a glob that does the job. C-style counting loops exist too:
for ((i = 1; i <= 5; i++)); do
echo "attempt $i"
done
And looping over script arguments is just for arg in "$@".
while: repeating on a condition
while runs its body as long as a command keeps succeeding. Its headline use is reading input line by line:
while IFS= read -r line; do
echo "host: $line"
done < hosts.txt
IFS= read -r is the incantation that preserves whitespace and backslashes; treat it as the fixed spelling of “read a line properly.” The same shape handles find output safely, null-delimited:
find /data -name "*.tmp" -print0 | while IFS= read -r -d '' f; do
rm -- "$f"
done
And the waiting-loop pattern:
while ! ping -c1 -W1 server01 >/dev/null 2>&1; do
sleep 5
done
echo "server is up"
case: cleaner than an if-ladder
When one value selects among several branches, case beats chained elifs and is the idiom behind every service-style script:
case "$1" in
start) echo "starting" ;;
stop) echo "stopping" ;;
restart) echo "restarting" ;;
*) echo "usage: $0 {start|stop|restart}" >&2; exit 1 ;;
esac
Patterns are globs, so *.txt, [Yy]*, and alternatives like start|begin all work as labels.
Functions: names for blocks
log() {
echo "[$(date +%T)] $*" >&2
}
backup_dir() {
local src=$1
local dest=$2
[[ -d $src ]] || { log "missing: $src"; return 1; }
tar -czf "$dest/$(basename "$src")-$(date +%F).tar.gz" "$src"
}
backup_dir /etc /backup && log "etc done"
Arguments arrive as $1, $2 just like script arguments. Two rules keep functions honest. First, declare working variables local, or every assignment silently writes globals and two functions using i will corrupt each other. Second, return only carries an exit status (0-255); actual data comes back by echoing it and capturing with result=$(myfunc), or by assigning to a variable the caller knows about.
Functions also compose with everything above: they have exit statuses, so they slot directly into if, &&, and while conditions, which is where bash control flow stops feeling like syntax and starts feeling like one consistent idea.
Putting it together
A script skeleton using the whole kit:
#!/usr/bin/env bash
set -euo pipefail
log() { echo "[$(date +%T)] $*" >&2; }
usage() { echo "usage: $0 {backup|clean} DIR" >&2; exit 1; }
[[ $# -eq 2 ]] || usage
case "$1" in
backup)
for f in "$2"/*.conf; do
[[ -e $f ]] || { log "nothing to back up"; break; }
cp -- "$f" "$f.bak" && log "backed up $f"
done
;;
clean)
find "$2" -name "*.bak" -print0 | while IFS= read -r -d '' f; do
rm -- "$f" && log "removed $f"
done
;;
*) usage ;;
esac
set -euo pipefail at the top makes the script stop on errors, undefined variables, and broken pipelines, converting silent misbehavior into loud failure, which is exactly the behavior you want while these constructs are becoming muscle memory.
Frequently Asked Questions
What is the difference between [ and [[ in bash?
Single bracket is the POSIX test command with word-splitting hazards. Double bracket is bash syntax that is safer with unquoted variables, supports pattern and regex matching, and allows && inside. In bash scripts, prefer double brackets.
How does if actually decide true or false?
if runs a command and branches on its exit status: zero means success and takes the then branch. The brackets are just a command that tests conditions and exits accordingly, which is why spacing around them is mandatory.
How do I loop over files safely?
Use globs directly, for f in *.log, and quote the variable everywhere it is used. Do not loop over ls output, which breaks on spaces. For find results, use find -print0 with a while read -d loop.
What is the difference between a for loop and a while loop?
for iterates over a known list of items, such as glob matches or arguments. while repeats as long as a command succeeds, which suits reading input line by line or waiting for a condition.
How do functions return values in bash?
The return statement only sets an exit status from 0 to 255. To return data, print it with echo and capture with command substitution, or assign to a variable the caller reads.
Why should function variables be declared local?
Without local, every assignment inside a function modifies global variables, so two functions using a counter named i can silently corrupt each other. Declare work variables local as a habit.