Bash Scripting Tutorial: From Beginner to Advanced

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

#!/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.sh

Strict 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 pipe

Variables 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-32767

User 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"
fi

Test 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
fi

Loops — 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]}"
done

while 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
done

Functions

#!/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]}"
done

String 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"
fi

File 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
fi

Error 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 $?"' ERR

Debugging

# 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.sh

Practical 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 -v

System 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}' | sort

Deploy 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
fi

Best Practices

  1. Always use #!/bin/bash at the top — not #!/bin/sh (different capabilities)
  2. Use set -euo pipefail for reliable error handling
  3. Always quote variables: Use "$var" not $var to handle spaces and special characters
  4. Use [[ not [ for conditionals — it's safer and more capable in bash
  5. Declare local variables in functions with local to prevent name conflicts
  6. Use mktemp for temporary files and clean them up with trap cleanup EXIT
  7. Write to stderr for errors: echo "Error" >&2
  8. Check your scripts with shellcheck: sudo apt install shellcheck && shellcheck script.sh
  9. Use long option flags in scripts (--recursive not -r) — easier to read a year later
  10. 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 AM

How 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.


Go up