Bash Scripting Tutorial: From Beginner to Advanced

✅ Tested with Bash 5.2 on Ubuntu 24.04, Debian 12, Arch Linux — Last updated: June 2026
Bash scripting turns repetitive manual tasks into automated, reliable programs. Whether you're automating backups, deploying applications, processing files, or building system administration tools, bash scripts are the glue that holds Linux systems together. This guide takes you from your first script to writing production-quality shell automation.
Table of Contents
- Your First Bash Script
- Variables and Data Types
- User Input and Arguments
- Conditionals — if, elif, else
- Loops — for, while, until
- Functions
- Arrays
- String Operations
- File Operations
- Error Handling
- Debugging
- Practical Scripts You Can Use Now
- Best Practices
Your First Bash Script
#!/bin/bash
# The shebang (first line) tells the OS which interpreter to use.
# #!/bin/bash = use bash. Never omit this line.
echo "Hello, World!"
echo "Today is $(date)"
echo "Current user: $USER"
echo "Working directory: $(pwd)"Save as hello.sh, make it executable, and run it:
chmod +x hello.sh
./hello.sh
# Or run without executing permissions:
bash hello.shStrict Mode — Always Use This
Start every serious script with these three options. They catch errors that would otherwise silently continue:
#!/bin/bash
set -euo pipefail
# -e = exit immediately when any command fails
# -u = treat unset variables as errors
# -o pipefail = return the exit code of the FIRST failed command in a pipeVariables and Data Types
#!/bin/bash
# Assign variables (no spaces around =):
name="Alice"
age=30
pi=3.14159
# Use variables with $:
echo "Name: $name"
echo "Age: $age"
# Use ${} for clarity or when adjacent to other text:
echo "User: ${name}_backup" # would fail as $name_backup
# Read-only (constants):
readonly MAX_RETRIES=5
# Environment variables (available to child processes):
export DATABASE_URL="postgresql://localhost/mydb"
# Command substitution:
current_date=$(date +%Y-%m-%d)
file_count=$(ls | wc -l)
echo "Files today ($current_date): $file_count"
# Arithmetic:
x=10
y=3
echo $((x + y)) # 13
echo $((x * y)) # 30
echo $((x / y)) # 3 (integer division)
echo $((x % y)) # 1 (modulo)
echo $((x ** 2)) # 100 (exponent)Special Variables
$0 # script name
$1, $2 # first and second arguments
$@ # all arguments as separate words
$* # all arguments as a single string
$# # number of arguments
$? # exit code of last command (0 = success)
$$ # current script's PID
$! # PID of last background process
$LINENO # current line number
$RANDOM # random number 0-32767User Input and Arguments
Command-Line Arguments
#!/bin/bash
# Usage: ./script.sh file1.txt file2.txt
if [[ $# -lt 1 ]]; then
echo "Usage: $0 [filename2...]"
exit 1
fi
echo "Script name: $0"
echo "First argument: $1"
echo "All arguments: $@"
echo "Number of arguments: $#"
# Iterate over all arguments:
for file in "$@"; do
echo "Processing: $file"
done Interactive Input with read
#!/bin/bash
read -p "Enter your name: " username
read -sp "Enter password: " password # -s = silent (no echo)
echo "" # new line after password
read -p "Continue? [y/N] " confirm
if [[ "${confirm,,}" == "y" ]]; then
echo "Continuing..."
fi
# Read with timeout:
read -t 10 -p "Answer within 10 seconds: " answer || echo "Timed out"
# Read into array:
read -ra items -p "Enter space-separated items: "Conditionals — if, elif, else
#!/bin/bash
# Basic if:
if [[ condition ]]; then
# do something
fi
# if-else:
if [[ condition ]]; then
echo "true"
else
echo "false"
fi
# if-elif-else:
score=75
if [[ $score -ge 90 ]]; then
echo "Grade: A"
elif [[ $score -ge 80 ]]; then
echo "Grade: B"
elif [[ $score -ge 70 ]]; then
echo "Grade: C"
else
echo "Grade: F"
fiTest Conditions
# Numeric comparisons:
[[ $a -eq $b ]] # equal
[[ $a -ne $b ]] # not equal
[[ $a -lt $b ]] # less than
[[ $a -gt $b ]] # greater than
[[ $a -le $b ]] # less than or equal
[[ $a -ge $b ]] # greater than or equal
# String comparisons:
[[ "$str" == "hello" ]] # equal
[[ "$str" != "hello" ]] # not equal
[[ -z "$str" ]] # empty string
[[ -n "$str" ]] # non-empty string
[[ "$str" =~ ^[0-9]+$ ]] # regex match (is it all digits?)
[[ "$str" < "$other" ]] # alphabetically less than
# File tests:
[[ -f "$file" ]] # exists and is a regular file
[[ -d "$dir" ]] # exists and is a directory
[[ -e "$path" ]] # exists (any type)
[[ -r "$file" ]] # readable
[[ -w "$file" ]] # writable
[[ -x "$file" ]] # executable
[[ -s "$file" ]] # exists and is not empty
[[ -L "$path" ]] # is a symbolic link
# Logical operators:
[[ cond1 && cond2 ]] # AND
[[ cond1 || cond2 ]] # OR
[[ ! condition ]] # NOT
# Practical examples:
if [[ -f "/etc/nginx/nginx.conf" ]]; then
echo "nginx is configured"
fi
if [[ -z "$DATABASE_URL" ]]; then
echo "ERROR: DATABASE_URL is not set" >&2
exit 1
fiLoops — for, while, until
for Loops
# Loop over a list:
for fruit in apple banana cherry; do
echo "Fruit: $fruit"
done
# Loop over files:
for file in /var/log/*.log; do
echo "Size of $file: $(wc -l < "$file") lines"
done
# C-style loop:
for ((i=1; i<=5; i++)); do
echo "Count: $i"
done
# Loop over command output:
for user in $(cut -d: -f1 /etc/passwd | head -5); do
echo "User: $user"
done
# Loop over lines in a file (safer):
while IFS= read -r line; do
echo "Line: $line"
done < /etc/hosts
# Loop over array:
fruits=("apple" "banana" "cherry")
for fruit in "${fruits[@]}"; do
echo "$fruit"
done
# Loop with index:
for i in "${!fruits[@]}"; do
echo "$i: ${fruits[$i]}"
donewhile and until Loops
# while loop:
counter=1
while [[ $counter -le 5 ]]; do
echo "Counter: $counter"
((counter++))
done
# until loop (opposite of while — runs UNTIL condition is true):
until [[ -f "/tmp/done.flag" ]]; do
echo "Waiting for done.flag..."
sleep 5
done
# Infinite loop with break:
while true; do
echo "Press Ctrl+C to stop"
sleep 1
done
# Retry with loop:
max_attempts=5
attempt=1
while [[ $attempt -le $max_attempts ]]; do
if curl -s https://example.com > /dev/null; then
echo "Success on attempt $attempt"
break
fi
echo "Attempt $attempt failed, retrying..."
((attempt++))
sleep 2
doneFunctions
#!/bin/bash
# Define a function:
greet() {
local name="$1" # local = variable only visible inside function
echo "Hello, $name!"
}
# Call it:
greet "Alice"
greet "Bob"
# Function with return value (exit code):
is_root() {
[[ $EUID -eq 0 ]] # returns 0 (true) if root, 1 (false) if not
}
if is_root; then
echo "Running as root"
else
echo "Not root — consider using sudo"
fi
# Function that outputs a value (use command substitution):
get_date() {
date +"%Y-%m-%d"
}
today=$(get_date)
echo "Today: $today"
# Practical function example:
log() {
local level="${1:-INFO}"
local message="$2"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $message"
}
log "INFO" "Script started"
log "ERROR" "Something failed"Arrays
#!/bin/bash
# Declare and populate:
fruits=("apple" "banana" "cherry" "date")
servers=("web01" "web02" "db01")
# Access elements:
echo "${fruits[0]}" # apple (0-indexed)
echo "${fruits[-1]}" # date (last element)
# All elements:
echo "${fruits[@]}"
# Number of elements:
echo "${#fruits[@]}" # 4
# Array slice:
echo "${fruits[@]:1:2}" # banana cherry (from index 1, take 2)
# Append:
fruits+=("elderberry")
# Remove element (leaves gap):
unset fruits[1]
# Loop (safe with spaces in elements):
for fruit in "${fruits[@]}"; do
echo "$fruit"
done
# Associative arrays (key-value, bash 4+):
declare -A config
config["host"]="localhost"
config["port"]="5432"
config["database"]="mydb"
echo "${config[host]}" # localhost
for key in "${!config[@]}"; do
echo "$key = ${config[$key]}"
doneString Operations
str="Hello, World!"
# Length:
echo "${#str}" # 13
# Substring: ${str:start:length}
echo "${str:0:5}" # Hello
echo "${str:7}" # World!
echo "${str: -6}" # orld! (last 6 chars)
# Remove prefix:
filename="image_backup_20260608.tar.gz"
echo "${filename#*_}" # backup_20260608.tar.gz (remove up to first _)
echo "${filename##*_}" # 20260608.tar.gz (remove up to last _)
# Remove suffix:
echo "${filename%.*}" # image_backup_20260608.tar (remove last extension)
echo "${filename%%.*}" # image_backup_20260608 (remove all extensions)
# Replace:
text="I like cats and more cats"
echo "${text/cats/dogs}" # I like dogs and more cats (first)
echo "${text//cats/dogs}" # I like dogs and more dogs (all)
# Case conversion:
lower="hello world"
echo "${lower^^}" # HELLO WORLD (uppercase)
upper="HELLO WORLD"
echo "${upper,,}" # hello world (lowercase)
# Default values:
echo "${UNSET_VAR:-default}" # print default if UNSET_VAR is empty/unset
: "${CONFIG_FILE:=/etc/app.conf}" # assign default if unset
# Check if contains substring:
if [[ "$str" == *"World"* ]]; then
echo "Contains World"
fiFile Operations
#!/bin/bash
# Read file line by line (correct way):
while IFS= read -r line; do
echo "Line: $line"
done < "/path/to/file.txt"
# Process CSV:
while IFS=, read -r name age city; do
echo "Name: $name, Age: $age, City: $city"
done < "data.csv"
# Find and process files:
find /var/log -name "*.log" -mtime -7 | while read -r logfile; do
size=$(du -sh "$logfile" | cut -f1)
echo "$logfile: $size"
done
# Create temp file safely:
tmpfile=$(mktemp)
trap "rm -f $tmpfile" EXIT # clean up on exit
echo "temporary data" > "$tmpfile"
# ... use tmpfile
# Append to file:
echo "New entry $(date)" >> /var/log/myapp.log
# Check disk space and alert:
usage=$(df -h /var/log | awk 'NR==2 {print $5}' | tr -d '%')
if [[ $usage -gt 80 ]]; then
echo "WARNING: /var/log is ${usage}% full" | mail -s "Disk Alert" admin@example.com
fiError Handling
#!/bin/bash
set -euo pipefail
# Trap errors:
trap 'echo "ERROR on line $LINENO: command failed" >&2' ERR
# Trap cleanup on exit (runs regardless of how script exits):
cleanup() {
echo "Cleaning up..."
rm -f /tmp/lockfile
}
trap cleanup EXIT
# Check exit codes:
if ! mkdir /tmp/mydir 2>/dev/null; then
echo "Failed to create directory" >&2
exit 1
fi
# Ignore errors for specific commands:
rm /tmp/file 2>/dev/null || true # || true prevents exit on failure
# Log errors to stderr:
error() {
echo "ERROR: $*" >&2
}
error "Something went wrong"
# Comprehensive error handler:
set -euo pipefail
trap 'error "Script failed at line $LINENO with exit code $?"' ERRDebugging
# Run with debug trace (shows each command before executing):
bash -x script.sh
# Enable/disable debug in script:
set -x # enable (shows commands)
# ... commands to debug ...
set +x # disable
# Dry run — show what would happen (for destructive scripts):
DRY_RUN=true
if [[ $DRY_RUN == true ]]; then
echo "[DRY RUN] Would delete: $file"
else
rm "$file"
fi
# Print variables for debugging:
declare -p my_array # print array with type info
printf 'var=%qn' "$variable" # print variable safely quoted
# Check bash syntax without running:
bash -n script.shPractical Scripts You Can Use Now
Backup Script
#!/bin/bash
set -euo pipefail
SOURCE_DIR="/home/$USER"
BACKUP_DIR="/media/external/backups"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/home_backup_$DATE.tar.gz"
echo "Starting backup of $SOURCE_DIR"
echo "Destination: $BACKUP_FILE"
mkdir -p "$BACKUP_DIR"
tar -czf "$BACKUP_FILE"
--exclude="$SOURCE_DIR/.cache"
--exclude="$SOURCE_DIR/Downloads"
"$SOURCE_DIR"
size=$(du -sh "$BACKUP_FILE" | cut -f1)
echo "✓ Backup complete: $BACKUP_FILE ($size)"
# Keep only last 7 backups:
ls -t "$BACKUP_DIR"/home_backup_*.tar.gz | tail -n +8 | xargs -r rm -vSystem Health Check
#!/bin/bash
echo "=== System Health Check - $(date) ==="
echo ""
# CPU
echo "CPU Load:"
uptime | awk -F'load average:' '{print " " $2}'
# Memory
echo ""
echo "Memory:"
free -h | awk 'NR==2 {printf " Used: %s / %s (%.0f%%)n", $3, $2, $3/$2*100}'
# Disk
echo ""
echo "Disk Usage:"
df -h | grep -vE '^Filesystem|tmpfs|cdrom' | awk '{print " " $5 " " $6}'
# Failed services
echo ""
echo "Failed Services:"
systemctl --failed --no-legend --no-pager | awk '{print " [FAIL] " $1}' || echo " None"
# Open ports
echo ""
echo "Listening Ports:"
ss -tulnp | grep LISTEN | awk '{print " " $5}' | sortDeploy Script
#!/bin/bash
set -euo pipefail
APP_DIR="/opt/myapp"
REPO_URL="git@github.com:user/myapp.git"
BRANCH="${1:-main}"
log() { echo "[$(date '+%H:%M:%S')] $*"; }
log "Deploying branch: $BRANCH"
# Pull latest code
cd "$APP_DIR"
git fetch origin
git checkout "$BRANCH"
git pull origin "$BRANCH"
# Install dependencies
log "Installing dependencies..."
npm ci --production
# Run database migrations
log "Running migrations..."
npm run migrate
# Restart service
log "Restarting application..."
sudo systemctl restart myapp
# Wait and verify
sleep 3
if systemctl is-active --quiet myapp; then
log "✓ Deployment successful"
else
log "✗ Service failed to start, rolling back..."
git checkout HEAD~1
sudo systemctl restart myapp
exit 1
fiBest Practices
- Always use
#!/bin/bashat the top — not#!/bin/sh(different capabilities) - Use
set -euo pipefailfor reliable error handling - Always quote variables: Use
"$var"not$varto handle spaces and special characters - Use
[[not[for conditionals — it's safer and more capable in bash - Declare local variables in functions with
localto prevent name conflicts - Use
mktempfor temporary files and clean them up withtrap cleanup EXIT - Write to stderr for errors:
echo "Error" >&2 - Check your scripts with
shellcheck:sudo apt install shellcheck && shellcheck script.sh - Use long option flags in scripts (
--recursivenot-r) — easier to read a year later - Test with a dry run before deploying destructive operations
Frequently Asked Questions
Should I use bash or Python for automation?
Use bash for: running system commands, chaining tools with pipes, simple file operations, and operations where you'd spend more time setting up Python than writing the script. Use Python for: complex logic, data processing, working with APIs, anything that needs structured data, or scripts longer than 200-300 lines. The rule of thumb: if you're fighting bash's syntax, switch to Python.
What's the difference between $() and backticks?
Both do command substitution: $(command) and `command` both run a command and return its output. Use $() — it's readable, nestable ($(echo $(date))), and doesn't have backslash escaping issues.
How do I run a script automatically on a schedule?
# Edit crontab:
crontab -e
# Format: minute hour day month weekday command
0 2 * * * /home/jm/scripts/backup.sh # every day at 2:00 AM
*/15 * * * * /home/jm/scripts/check.sh # every 15 minutes
0 9 * * 1 /home/jm/scripts/report.sh # every Monday at 9 AMHow do I run a script as root without logging in as root?
Use sudo ./script.sh for one-off execution. For automated scripts, add to /etc/sudoers with sudo visudo: username ALL=(ALL) NOPASSWD: /path/to/script.sh. Only grant NOPASSWD for specific scripts, not all commands.
Bash scripting is a skill that compounds — every script you write teaches you patterns you'll reuse. Start with automating one repetitive task, and within a month you'll have a collection of tools that save you hours every week. Check our Linux commands cheat sheet for the building blocks your scripts will use.
