Bash Functions, Control Flow, and Exit Codes
Write reusable Bash functions with local scope, proper exit codes, and return values. Master if, case, for, while, and until control flow. Lesson 5 of the Linux & Bash Scripting course.

Functions transform a script from a linear sequence of commands into a composable collection of named operations. Combined with clean control flow and disciplined use of exit codes, they are the foundation of scripts that are readable, testable, and reusable across projects.
Previous: Lesson 4 — Variables, Arrays, and String Manipulation
Defining and Calling Functions
#!/usr/bin/env bash
# Two equivalent syntaxes — prefer the second for clarity
function greet { echo "Hello, $1"; }
greet() { echo "Hello, $1"; }
# Multi-line function
deploy_service() {
local service_name="$1"
local version="$2"
echo "Deploying ${service_name} v${version}..."
systemctl restart "${service_name}"
}
# Call like a command
deploy_service "nginx" "1.24"Functions must be defined before they are called. Organise scripts with all function definitions at the top and a main() function called at the bottom.
Return Values and Exit Codes
Bash functions do not return values in the traditional sense. They return an exit code (0–255). To pass data back to the caller, use stdout capture or a global variable:
#!/usr/bin/env bash
# Pattern 1: return via stdout (captured by caller)
get_timestamp() {
date '+%Y-%m-%d_%H:%M:%S'
}
ts=$(get_timestamp)
echo "Timestamp: $ts"
# Pattern 2: return via a named output variable (nameref — Bash 4.3+)
get_free_port() {
local -n _result="$1" # nameref — alias for the caller's variable
_result=$(python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()")
}
get_free_port my_port
echo "Free port: $my_port"
# Pattern 3: return exit code to signal success/failure
file_exists() {
[[ -f "$1" ]]
}
if file_exists "/etc/nginx/nginx.conf"; then
echo "Config found"
else
echo "Config missing" >&2
exit 1
fiConditionals
[[ ]] vs [ ] vs (( ))
| Syntax | Use for |
|---|---|
[[ ]] | String and file tests — always prefer over [ ] in Bash |
[ ] | POSIX portable tests — needed only when writing sh-compatible scripts |
(( )) | Integer arithmetic tests |
# String tests
[[ -z "$str" ]] # true if empty
[[ -n "$str" ]] # true if non-empty
[[ "$a" == "$b" ]] # string equality
[[ "$a" != "$b" ]] # string inequality
[[ "$str" == *"pattern"* ]] # glob match (no quotes around pattern)
[[ "$str" =~ ^[0-9]+$ ]] # regex match (ERE, unquoted)
# File tests
[[ -f "$path" ]] # regular file exists
[[ -d "$path" ]] # directory exists
[[ -r "$path" ]] # readable
[[ -w "$path" ]] # writable
[[ -x "$path" ]] # executable
[[ -s "$path" ]] # non-empty file
[[ -L "$path" ]] # symbolic link
# Integer tests (inside [[ ]])
[[ "$a" -eq "$b" ]] # equal
[[ "$a" -ne "$b" ]] # not equal
[[ "$a" -lt "$b" ]] # less than
[[ "$a" -ge "$b" ]] # greater than or equal
# Integer tests (inside (( )) — cleaner for arithmetic)
(( a == b ))
(( a < b ))
(( a >= 100 ))if / elif / else
#!/usr/bin/env bash
check_service() {
local service="$1"
if systemctl is-active --quiet "$service"; then
echo "${service}: running"
elif systemctl is-enabled --quiet "$service"; then
echo "${service}: stopped (but enabled)"
else
echo "${service}: stopped and disabled"
fi
}
check_service nginx
check_service postgresqlcase
case is cleaner than a long if/elif chain when matching a variable against multiple string patterns:
#!/usr/bin/env bash
deploy() {
local env="$1"
case "$env" in
production|prod)
echo "Deploying to production"
export REPLICAS=5
export LOG_LEVEL="warn"
;;
staging|stage)
echo "Deploying to staging"
export REPLICAS=2
export LOG_LEVEL="info"
;;
development|dev|local)
echo "Deploying locally"
export REPLICAS=1
export LOG_LEVEL="debug"
;;
*)
echo "Unknown environment: $env" >&2
return 1
;;
esac
}Loops
for Loop
# Loop over a list
for env in production staging development; do
echo "Checking $env..."
done
# Loop over an array
servers=("web01" "web02" "db01")
for server in "${servers[@]}"; do
ssh "$server" 'systemctl status nginx'
done
# C-style for loop
for (( i=0; i<10; i++ )); do
echo "Iteration $i"
done
# Loop over files
for config in /etc/nginx/conf.d/*.conf; do
nginx -t -c "$config" && echo "OK: $config"
done
# Loop over command output (safe with mapfile)
mapfile -t pods < <(kubectl get pods -o name)
for pod in "${pods[@]}"; do
kubectl describe "$pod"
donewhile and until
# Retry loop with backoff
attempt=0
max_attempts=5
delay=2
until curl -sf http://localhost:8080/health; do
(( attempt++ ))
if (( attempt >= max_attempts )); then
echo "Service failed to start after ${max_attempts} attempts" >&2
exit 1
fi
echo "Attempt ${attempt}/${max_attempts} failed. Retrying in ${delay}s..."
sleep "$delay"
(( delay *= 2 )) # exponential backoff
done
echo "Service is healthy"# Read a file line by line
while IFS= read -r line; do
echo "Processing: $line"
done < /etc/hosts
# Process pipeline output line by line
docker ps --format '{{.Names}}' | while IFS= read -r container; do
echo "Container: $container"
docker stats --no-stream "$container"
doneLoop Control
for i in {1..20}; do
if (( i % 3 == 0 )); then
continue # skip multiples of 3
fi
if (( i > 15 )); then
break # stop at 15
fi
echo "$i"
doneStructuring Scripts with a main() Function
The pattern below separates definition from execution, makes the script sourceable without running, and ensures all variables are scoped:
#!/usr/bin/env bash
set -euo pipefail
# ---------- Constants ----------
readonly SCRIPT_NAME="$(basename "$0")"
readonly LOG_DIR="/var/log/myapp"
# ---------- Functions ----------
log_info() { echo "[INFO] $(date '+%H:%M:%S') $*"; }
log_error() { echo "[ERROR] $(date '+%H:%M:%S') $*" >&2; }
usage() {
echo "Usage: ${SCRIPT_NAME} <environment> [--dry-run]"
exit 1
}
deploy() {
local env="$1"
local dry_run="${2:-false}"
log_info "Starting deploy to ${env} (dry_run=${dry_run})"
if [[ "$dry_run" == "true" ]]; then
log_info "Dry run — no changes made"
return 0
fi
# ... deployment logic ...
}
# ---------- Main ----------
main() {
local environment="${1:-}"
local dry_run="false"
[[ -z "$environment" ]] && usage
if [[ "${2:-}" == "--dry-run" ]]; then
dry_run="true"
fi
deploy "$environment" "$dry_run"
}
main "$@"