Bash Scripting: A Complete Guide
Bash (Bourne Again SHell) is the most widely used Unix shell and scripting language. A well-written Bash script automates repetitive tasks, enforces consistency, and documents system administration procedures.
Script Structure
#!/usr/bin/env bash
#
# Script: backup.sh
# Description: Creates timestamped backups with rotation
# Author: Your Name
# Usage: ./backup.sh <source_dir> [destination_dir]
set -euo pipefail
# --- Configuration ---
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
# --- Functions ---
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
---
# --- Main ---
main() {
log "Starting backup..."
# actual logic here
log "Backup complete"
---
main "$@"The Shebang
The first line #!/usr/bin/env bash is the shebang. It tells the system to use Bash to interpret the script. Using /usr/bin/env bash is more portable than hardcoding /bin/bash because env finds bash in the user’s PATH.
Strict Mode
set -euo pipefailset -e— exit immediately if a command fails (returns non-zero)set -u— treat unset variables as an errorset -o pipefail— pipelines fail if any command in the pipe fails- These three options catch most scripting mistakes early
Variables
# Assignment (no spaces around =)
name="Alice"
count=5
result=$(ls -la) # command substitution
today=$(date +%Y-%m-%d)
# Usage
echo "$name" # always quote variables!
echo "Hello, ${name}!" # braces for clarity/expansion
# Default values
echo "${name:-default}" # use default if unset or empty
echo "${name:=default}" # assign default if unset
echo "${name:?error msg}" # error if unset
echo "${name:+alt}" # use alt if set, else nothing
# Readonly and local
readonly API_KEY="abc123" # cannot be changed
local temp_var="temporary" # function-local scope (inside functions)
# Arrays
files=("file1.txt" "file2.txt" "file3.txt")
echo "${files[0]}" # 'file1.txt'
echo "${files[@]}" # all elements
echo "${#files[@]}" # array length
# Associative arrays (Bash 4+)
declare -A config
config[host]="localhost"
config[port]=8080
echo "${config[host]}"String Operations
string="hello-world.txt"
# Length
echo "${#string}" # 16
# Substring
echo "${string:0:5}" # 'hello'
echo "${string:6}" # 'world.txt'
# Pattern removal (prefix)
echo "${string#*.}" # 'txt' — remove shortest prefix matching *.
echo "${string##*.}" # 'txt' — remove longest prefix
# Pattern removal (suffix)
echo "${string%.*}" # 'hello-world' — remove shortest suffix
echo "${string%%.*}" # 'hello' — remove longest suffix
# Replace
echo "${string/hello/hi}" # 'hi-world.txt' (first match)
echo "${string//o/_}" # 'hell_-w_rld.txt' (all matches)
echo "${string/#hello/hi}" # replace at start
echo "${string/%txt/csv}" # replace at end
# Case modification (Bash 4+)
echo "${string^^}" # uppercase: 'HELLO-WORLD.TXT'
echo "${string,,}" # lowercase: 'hello-world.txt'
echo "${string^}" # capitalize first characterControl Flow
Conditionals
# if/then/elif/else/fi
if [[ -f "$file" ]]; then
echo "File exists"
elif [[ -d "$file" ]]; then
echo "Directory exists"
else
echo "Not found"
fi
# File tests
[[ -f "$file" ]] # regular file exists
[[ -d "$dir" ]] # directory exists
[[ -e "$path" ]] # any file system entry exists
[[ -x "$bin" ]] # is executable
[[ -z "$var" ]] # string is empty
[[ -n "$var" ]] # string is not empty
# String comparison
[[ "$a" == "$b" ]] # equal (use == not =)
[[ "$a" != "$b" ]] # not equal
[[ "$a" < "$b" ]] # lexicographic less than
[[ -z "$var" ]] # empty
# Numeric comparison
(( count > 5 )) # arithmetic context (preferred)
[[ $count -gt 5 ]] # old test style
# Pattern matching
[[ "$filename" == *.txt ]] # wildcard match
[[ "$email" =~ ^[a-z]+@ ]] # regex match
# Logical operators
[[ -n "$var" && -f "$file" ]] # and
[[ -n "$var" || -f "$file" ]] # or
[[ ! -f "$file" ]] # notLoops
# For loop over items
for item in one two three; do
echo "$item"
done
# For loop with range (Bash 3+)
for i in {1..5}; do
echo "$i"
done
# For loop with C-style syntax
for ((i = 0; i < 5; i++)); do
echo "$i"
done
# While loop
while read -r line; do
echo "Line: $line"
done < input.txt
# While with counter
count=0
while (( count < 5 )); do
echo "$count"
((count++))
done
# Loop over command output
for file in *.txt; do
[[ -f "$file" ]] || continue # skip if no matches
echo "Processing: $file"
done
# Loop over array
for item in "${files[@]}"; do
echo "$item"
doneFunctions
# Define function
greet() {
local name="$1" # first argument
local greeting="${2:-Hello}" # second arg with default
if [[ -z "$name" ]]; then
echo "Usage: greet <name> [greeting]" >&2
return 1
fi
echo "${greeting}, ${name}!"
---
# Call function
greet "Alice" # "Hello, Alice!"
greet "Bob" "Hi" # "Hi, Bob!"
greet # prints usage and returns 1
# Return values
get_os() {
case "$(uname)" in
Linux) echo "linux" ;;
Darwin) echo "macos" ;;
*) echo "unknown" ;;
esac
---
os=$(get_os)
echo "Running on $os"
# Functions returning numeric status (0-255)
is_valid_port() {
(( $1 > 0 && $1 <= 65535 ))
---
if is_valid_port 8080; then
echo "Port is valid"
fiArgument Parsing
#!/usr/bin/env bash
usage() {
echo "Usage: $0 [options] <input>"
echo " -o, --output FILE Output file"
echo " -v, --verbose Verbose output"
echo " -h, --help Show this help"
exit 1
---
# Parse with getopt
args=$(getopt -o o:vh --long output:,verbose,help -n "$0" -- "$@")
if [[ $? -ne 0 ]]; then usage; fi
eval set -- "$args"
verbose=false
output=""
while true; do
case "$1" in
-o|--output)
output="$2"
shift 2
;;
-v|--verbose)
verbose=true
shift
;;
-h|--help)
usage
;;
--)
shift
break
;;
esac
done
input="${1:?Missing input file}" # required positional arg
echo "Input: $input"
echo "Output: ${output:-stdout}"
echo "Verbose: $verbose"Error Handling
# Custom error handler
error_handler() {
local line=$1
local code=$2
echo "Error on line $line: exit code $code" >&2
---
trap 'error_handler $LINENO $?' ERR
# Ensure cleanup on exit
cleanup() {
rm -f /tmp/tempfile.$$
echo "Cleanup done"
---
trap cleanup EXIT
# Check command existence
command -v curl >/dev/null 2>&1 || {
echo "curl is required but not installed." >&2
exit 1
---
# Fail fast with &&/|| chains
mkdir -p "$backup_dir" || {
echo "Failed to create backup directory" >&2
exit 1
---Real-World Implementation Tips
Production Considerations
When moving from development to production, several factors become critical. Error handling should be comprehensive — every external call (database, API, file system) should have proper error checking, logging, and retry logic where appropriate. Performance monitoring through metrics and structured logging helps identify bottlenecks before they affect users.
Testing Strategy
A thorough testing approach combines multiple levels:
- Unit tests verify individual functions and methods in isolation
- Integration tests validate that components work together correctly
- Edge case tests cover boundary conditions, empty inputs, and error states
- Performance tests ensure the system meets latency and throughput requirements
Test data should be realistic but controlled. Mock external dependencies to make tests fast and deterministic. Aim for tests that are independent, repeatable, and fast enough to run on every commit.
Documentation
Good documentation is essential for maintainable code. Follow these principles:
- Document the “why” not just the “what” — explain design decisions
- Keep examples up to date with the code
- Include usage examples for public APIs
- Document configuration options and their defaults
- Explain error conditions and recovery strategies
Security Best Practices
Security should be considered throughout development:
- Validate all inputs at system boundaries
- Use parameterized queries for database access
- Store secrets in environment variables or secret managers
- Keep dependencies updated to patch vulnerabilities
- Apply the principle of least privilege
Performance Optimization
Optimize based on measured data, not assumptions:
- Profile before optimizing — identify actual bottlenecks
- Measure the impact of each change
- Consider the trade-off between speed and readability
- Cache expensive operations with appropriate invalidation
- Use connection pooling for database and network resources
Monitoring and Observability
Production systems need visibility:
- Structured logging with correlation IDs for request tracking
- Metrics for latency, throughput, error rates, and resource usage
- Health check endpoints for load balancers and orchestration
- Distributed tracing for request flows across services
- Alerts for anomaly detection based on baselines
These patterns apply across all programming languages and frameworks. The specific implementation varies, but the principles remain consistent.
FAQ
Q: Should I use () or {} for variable expansion?
A: Use ${var} when followed by alphanumeric characters (e.g., ${var}_suffix). Braces are also needed for array indexing and string operations.
Q: What is the difference between [ and [[?
A: [[ is a Bash keyword with more features: pattern matching, regex, no word splitting. [ is the old POSIX test command. Prefer [[ in Bash scripts.
Q: How do I debug a Bash script?
A: Run with bash -x script.sh for trace output. Add set -x in specific sections. Use bash -n script.sh for syntax checking.
Q: How do I handle spaces in filenames?
A: Always quote variables ("$file"), use IFS=$'\n' for looping over filenames, and use -- to separate options from arguments.
Q: What is the difference between $ and $@?*
A: $* expands to a single string (“a b c”). $@ expands to separate strings (“a” “b” “c”). Always use "$@" to preserve quoting.
Q: How do I write portable scripts?
A: Use /usr/bin/env bash in shebang, avoid Bash-specific features if targeting POSIX sh, test on multiple shells, and use shellcheck to catch portability issues.
For a comprehensive overview, read our article on Bash Scripting Basics.
For a comprehensive overview, read our article on Cron Jobs Guide.