Bash Shell Scripting
A Complete Tutorial — From Basics to Advanced Control Structures
All scripts use #!/bin/bash and run on GNU/Linux & macOS
■ Table of Contents
1. Introduction to Shell Scripting
2. Your First Bash Script
3. Variables & Data Types
4. User Input & Arguments
5. Arithmetic & String Operations
6. Conditional Statements (if / elif / else / case)
7. Loops — for, while, until, select
8. Functions
9. Arrays
10. File & Directory Operations
11. Regular Expressions & Text Processing
12. Process Management & Signal Handling
13. Advanced Topics & Best Practices
1. Introduction to Shell Scripting
A shell is a command-line interpreter that provides a user interface to the Unix/Linux operating system. Bash
(Bourne Again SHell) is the most widely used shell on Linux systems and is the default on most distributions and
macOS. A shell script is simply a text file containing a sequence of shell commands that are executed one after
another.
Why Learn Bash Scripting?
• Automate repetitive system administration tasks
• Manage files, processes, and system resources
• Write deployment and build pipelines (CI/CD)
• Parse logs and transform text data
• Schedule jobs with cron and at
• Glue together other programs and tools
The Shebang Line
Every Bash script should start with a shebang line. This special first line tells the operating system which interpreter
to use when the file is executed directly.
#!/bin/bash
#
# Every script in this tutorial starts with the line above.
# The '#!' is called a 'shebang' or 'hashbang'.
# /bin/bash is the absolute path to the Bash interpreter.
■ Note: Always use /bin/bash (not /bin/sh) to ensure Bash-specific features work correctly. On some systems sh is
a different shell (dash, ksh, etc.).
Making a Script Executable
# Create the file
touch [Link]
# Add execute permission for the owner
chmod +x [Link]
# Run it
./[Link]
# Or run it explicitly with bash (no chmod needed)
bash [Link]
2. Your First Bash Script
Let's write a classic Hello World script and then progressively make it more interesting.
#!/bin/bash
# Script: [Link]
# Description: Our very first Bash script
echo 'Hello, World!'
echo 'Welcome to Bash scripting!'
echo "Today is: $(date '+%A, %B %d %Y')"
echo "You are running as: $(whoami)"
echo "Current directory: $(pwd)"
Comments
Lines beginning with # are comments and are ignored by Bash. Good comments explain why, not what.
#!/bin/bash
# ■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
# Script : [Link]
# Author : Your Name
# Date : 2025-01-01
# Purpose : Demonstrate comments and basic output
# ■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
# This is a single-line comment
echo 'Running [Link] ...' # Inline comment
: '
This is a multi-line comment block.
Everything between the colon-quote and the closing
quote is treated as a no-op string.
'
echo 'Done!'
echo vs printf
#!/bin/bash
# echo — simple, adds newline automatically
echo 'Simple output'
echo -n 'No newline at end' # -n suppresses newline
echo -e 'Tab:\there Newline:\nhere' # -e enables escapes
# printf — powerful, C-style formatting
printf 'Name: %-15s Age: %d\n' 'Alice' 30
printf 'Pi is approximately %.4f\n' 3.14159265
printf '%05d\n' 42 # Pad with zeros → 00042
3. Variables & Data Types
Bash variables are untyped by default — they store strings. Variable names are case-sensitive and may contain
letters, digits, and underscores (but must not start with a digit).
#!/bin/bash
# ■■ Assigning variables (NO spaces around '=') ■■
name='Alice'
age=30
city='Nairobi'
pi=3.14159
# ■■ Accessing variables with $ ■■
echo "Name : $name"
echo "Age : $age"
echo "City : $city"
# ■■ Curly braces disambiguate ■■
fruit='apple'
echo "I have ${fruit}s" # → I have apples
echo "I have $fruits" # → error: fruits undefined
# ■■ Command substitution ■■
today=$(date '+%Y-%m-%d')
files=$(ls | wc -l)
echo "Date: $today | Files: $files"
# ■■ Read-only (constant) variables ■■
readonly MAX_SIZE=1024
readonly DB_HOST='localhost'
Variable Scope — local vs global
#!/bin/bash
GLOBAL_VAR='I am global'
my_function() {
local LOCAL_VAR='I am local'
echo "Inside: GLOBAL=$GLOBAL_VAR"
echo "Inside: LOCAL=$LOCAL_VAR"
}
my_function
echo "Outside: GLOBAL=$GLOBAL_VAR"
echo "Outside: LOCAL=${LOCAL_VAR:-'(empty)'}"
# LOCAL_VAR is empty here because it was declared local
Special / Automatic Variables
Variable Meaning
$0 Name of the script
$1 … $9 Positional parameters (arguments)
$@ All arguments as separate words
$* All arguments as a single word
$# Number of arguments
$? Exit status of last command (0 = success)
$$ PID of the current shell / script
$! PID of last background process
$_ Last argument of the previous command
$RANDOM Random integer 0–32767
$LINENO Current line number in the script
$HOME Current user's home directory
$PATH Executable search path
$USER Current username
$HOSTNAME Machine hostname
$OLDPWD Previous working directory
4. User Input & Arguments
Reading User Input with read
#!/bin/bash
# Basic read
echo -n 'Enter your name: '
read name
echo "Hello, $name!"
# read with prompt flag (-p)
read -p 'Enter your age: ' age
echo "You are $age years old."
# read with timeout (-t) and silent mode (-s for passwords)
read -s -p 'Enter password: ' password
echo # newline after silent input
echo "Password length: ${#password} chars"
# read multiple values at once
read -p 'Enter first and last name: ' first last
echo "First: $first Last: $last"
# read into an array
read -a colours -p 'Enter colours (space-separated): '
echo "First colour: ${colours[0]}"
Command-Line Arguments
#!/bin/bash
# Usage: ./[Link] Alice 30 'Software Engineer'
echo "Script name : $0"
echo "1st arg : $1"
echo "2nd arg : $2"
echo "3rd arg : $3"
echo "All args : $@"
echo "Arg count : $#"
# Loop through all arguments
echo '--- All arguments ---'
for arg in "$@"; do
echo " → $arg"
done
# Shift — discard $1 and shift remaining args left
echo "Before shift: \$1=$1"
shift
echo "After shift: \$1=$1"
getopts — Parsing Flags
#!/bin/bash
# Usage: ./[Link] -n Alice -a 30 -v
verbose=false
name=''
age=''
while getopts ':n:a:v' opt; do
case $opt in
n) name=$OPTARG ;;
a) age=$OPTARG ;;
v) verbose=true ;;
:) echo "Option -$OPTARG requires an argument"; exit 1 ;;
?) echo "Unknown option: -$OPTARG"; exit 1 ;;
esac
done
$verbose && echo '[verbose mode ON]'
echo "Name: $name Age: $age"
5. Arithmetic & String Operations
Integer Arithmetic
#!/bin/bash
a=10 b=3
# Method 1: $(( )) — arithmetic expansion (preferred)
echo "Add : $((a + b))"
echo "Subtract : $((a - b))"
echo "Multiply : $((a * b))"
echo "Divide : $((a / b))" # integer division
echo "Modulo : $((a % b))"
echo "Power : $((a ** b))"
# Increment / decrement
counter=0
((counter++)) # post-increment
((counter+=5)) # add 5
echo "Counter: $counter"
# Method 2: expr (older, slower)
result=$(expr $a \* $b) # escape * to avoid glob
echo "expr result: $result"
# Method 3: let
let 'result = a * b + 1'
echo "let result: $result"
# Floating-point requires bc or awk
echo 'scale=4; 22/7' | bc # → 3.1428
awk 'BEGIN{printf "%.6f\n", 22/7}'
String Operations
#!/bin/bash
str='Hello, World!'
# Length
echo "Length : ${#str}" # → 13
# Substring ${var:start:length}
echo "Substr : ${str:7:5}" # → World
echo "From end : ${str: -6:5}" # → World
# Case conversion (Bash 4+)
echo "Upper : ${str^^}"
echo "Lower : ${str,,}"
echo "First upper: ${str^}"
# Pattern removal
file='/home/alice/[Link]'
echo "Remove prefix : ${file#*/}" # home/alice/[Link]
echo "Remove all : ${file##*/}" # [Link] (basename)
echo "Remove suffix : ${file%.*}" # /home/alice/report
echo "Remove ext : ${file%%.*}" # /home/alice/report
# Substitution ${var/pattern/replacement}
echo "Replace first : ${str/l/L}" # HeLlo, World!
echo "Replace all : ${str//l/L}" # HeLLo, WorLd!
# Default values
echo "Value or default: ${unset_var:-'default'}"
echo "Set if empty : ${var:='assigned'}"
# Concatenation
greeting='Hello'
name='Alice'
full="${greeting}, ${name}!"
echo "$full"
6. Conditional Statements
Bash offers several ways to make decisions: if/elif/else, case, and short-circuit operators && and ||.
if / elif / else
#!/bin/bash
read -p 'Enter a number: ' num
if [[ $num -gt 100 ]]; then
echo 'Greater than 100'
elif [[ $num -gt 50 ]]; then
echo 'Greater than 50'
elif [[ $num -gt 0 ]]; then
echo 'Positive number'
elif [[ $num -eq 0 ]]; then
echo 'Zero'
else
echo 'Negative number'
fi
Test Operators
Operator Type Meaning
-eq Integer Equal
-ne Integer Not equal
-lt Integer Less than
-le Integer Less than or equal
-gt Integer Greater than
-ge Integer Greater than or equal
= String Equal
!= String Not equal
-z String Empty (zero length)
-n String Non-empty
=~ String Matches regex (inside [[ ]])
-e File File/dir exists
-f File Regular file
-d File Directory
-r / -w / -x File Readable / Writable / Executable
Operator Type Meaning
-s File File size > 0
-L File Symbolic link
! expr Logic NOT
&& / || Logic AND / OR
case Statement
#!/bin/bash
read -p 'Enter a fruit name: ' fruit
case $fruit in
apple | Apple)
echo 'A red or green fruit'
;;
banana)
echo 'A yellow fruit'
;;
mango | papaya)
echo 'A tropical fruit'
;;
[0-9]*)
echo 'That looks like a number, not a fruit!'
;;
*)
echo "Unknown fruit: $fruit"
;;
esac
Short-circuit Operators
#!/bin/bash
# && runs right side ONLY if left side succeeds (exit 0)
mkdir -p /tmp/mydir && echo 'Directory created'
# || runs right side ONLY if left side fails (exit != 0)
rm /tmp/no_such_file 2>/dev/null || echo 'File not found — OK'
# Combine to mimic if/else on one line
[[ -f '/etc/passwd' ]] && echo 'passwd exists' || echo 'Missing!'
7. Loops — for, while, until, select
for Loop
#!/bin/bash
# ■■ Classic list-style for ■■
for colour in red green blue yellow; do
echo "Colour: $colour"
done
# ■■ C-style for (like C/Java) ■■
for ((i=1; i<=5; i++)); do
printf 'Square of %d = %d\n' $i $((i*i))
done
# ■■ Brace expansion (sequence) ■■
for n in {1..10}; do
echo -n "$n "
done; echo
# ■■ Step in brace expansion {start..end..step} ■■
for n in {0..20..5}; do
echo -n "$n "
done; echo
# ■■ Loop over files ■■
for file in /etc/*.conf; do
echo "Config: $(basename $file)"
done
# ■■ Loop over command output ■■
for user in $(cut -d: -f1 /etc/passwd | head -5); do
echo "User: $user"
done
while Loop
#!/bin/bash
# ■■ Count from 1 to 5 ■■
counter=1
while [[ $counter -le 5 ]]; do
echo "Counter: $counter"
((counter++))
done
# ■■ Read a file line by line ■■
while IFS= read -r line; do
echo "Line: $line"
done < /etc/hostname
# ■■ Infinite loop with break ■■
while true; do
read -p 'Type quit to exit: ' input
[[ $input == 'quit' ]] && break
echo "You typed: $input"
done
echo 'Goodbye!'
# ■■ while reading pipeline ■■
ls -1 /tmp | while read -r fname; do
echo "Found: $fname"
done
until Loop
#!/bin/bash
# until runs while condition is FALSE (opposite of while)
n=10
until [[ $n -le 0 ]]; do
echo -n "$n "
((n--))
done
echo 'Blast off!'
# Wait until a file appears
until [[ -f /tmp/[Link] ]]; do
echo 'Waiting for /tmp/[Link] ...'
sleep 2
done
echo 'Flag found — continuing!'
Loop Control — break & continue
#!/bin/bash
# break — exit the loop immediately
for i in {1..10}; do
[[ $i -eq 6 ]] && break
echo -n "$i "
done; echo # → 1 2 3 4 5
# continue — skip to next iteration
for i in {1..10}; do
(( i % 2 == 0 )) && continue # skip even numbers
echo -n "$i "
done; echo # → 1 3 5 7 9
# break N — break out of N nested loops
for i in 1 2 3; do
for j in A B C; do
[[ $i -eq 2 && $j == 'B' ]] && break 2
echo "$i-$j"
done
done
echo 'After nested break'
select Loop (Interactive Menus)
#!/bin/bash
PS3='Choose your shell: ' # custom prompt for select
options=('bash' 'zsh' 'fish' 'ksh' 'Quit')
select shell in "${options[@]}"; do
case $shell in
Quit)
echo 'Exiting.'
break
;;
'')
echo 'Invalid selection.'
;;
*)
echo "You chose: $shell"
;;
esac
done
8. Functions
Functions let you encapsulate reusable logic. In Bash, functions are defined before they are called and share the
global environment unless variables are declared local.
#!/bin/bash
# ■■ Two equivalent syntaxes ■■
greet() {
echo "Hello, $1!"
}
function farewell {
echo "Goodbye, $1!"
}
greet 'Alice'
farewell 'Bob'
# ■■ Return values ■■
# Bash functions return an exit code (0-255), not a value.
# To return data, use stdout + command substitution.
add() {
echo $(( $1 + $2 ))
}
result=$(add 7 13)
echo "7 + 13 = $result"
# ■■ Return exit code ■■
is_even() {
(( $1 % 2 == 0 ))
}
is_even 4 && echo '4 is even' || echo '4 is odd'
is_even 7 && echo '7 is even' || echo '7 is odd'
Advanced Function Patterns
#!/bin/bash
# ■■ Default parameter values ■■
greet() {
local name=${1:-'World'}
local greeting=${2:-'Hello'}
echo "$greeting, $name!"
}
greet # → Hello, World!
greet 'Alice' # → Hello, Alice!
greet 'Bob' 'Howdy' # → Howdy, Bob!
# ■■ Recursive functions ■■
factorial() {
local n=$1
[[ $n -le 1 ]] && echo 1 && return
echo $(( n * $(factorial $((n - 1))) ))
}
echo "5! = $(factorial 5)" # → 120
# ■■ Function libraries — source other files ■■
# In [Link]: log() { echo "[$(date '+%H:%M:%S')] $*"; }
# source ./[Link] # loads the library
# . ./[Link] # identical short form
# log 'Script started'
9. Arrays
Indexed Arrays
#!/bin/bash
# ■■ Declaration & assignment ■■
fruits=('apple' 'banana' 'cherry' 'date')
declare -a colours # explicit declaration (optional)
colours[0]='red'
colours[1]='green'
colours[2]='blue'
# ■■ Access elements ■■
echo "${fruits[0]}" # apple
echo "${fruits[2]}" # cherry
echo "${fruits[-1]}" # date (last element, Bash 4.3+)
# ■■ All elements ■■
echo "${fruits[@]}" # apple banana cherry date
echo "${fruits[*]}" # same but affected by IFS
# ■■ Array length ■■
echo "${#fruits[@]}" # 4
# ■■ Slice ${arr[@]:start:length} ■■
echo "${fruits[@]:1:2}" # banana cherry
# ■■ Append ■■
fruits+=('elderberry' 'fig')
echo "${fruits[@]}"
# ■■ Iterate ■■
for fruit in "${fruits[@]}"; do
echo " - $fruit"
done
# ■■ Iterate with index ■■
for i in "${!fruits[@]}"; do
printf '[%d] %s\n' $i "${fruits[$i]}"
done
# ■■ Delete element ■■
unset 'fruits[1]'
echo "After delete: ${fruits[@]}"
Associative Arrays (Dictionaries)
#!/bin/bash
# Requires Bash 4+
declare -A person
person['name']='Alice'
person['age']=30
person['city']='Nairobi'
# Access
echo "Name: ${person['name']}"
echo "Age : ${person['age']}"
# All keys
echo "Keys : ${!person[@]}"
# All values
echo "Values: ${person[@]}"
# Iterate
for key in "${!person[@]}"; do
printf '%-10s: %s\n' "$key" "${person[$key]}"
done
10. File & Directory Operations
#!/bin/bash
# ■■ Check and create ■■
TARGET='/tmp/demo_dir'
[[ -d $TARGET ]] || mkdir -p "$TARGET"
echo 'Directory ready'
# ■■ Write to a file ■■
cat > "$TARGET/[Link]" << 'EOF'
Line 1: Hello from heredoc
Line 2: This overwrites the file
EOF
# Append
echo 'Line 3: Appended' >> "$TARGET/[Link]"
# ■■ Read file ■■
echo '--- File contents ---'
while IFS= read -r line; do
echo " $line"
done < "$TARGET/[Link]"
# ■■ Copy, move, delete ■■
cp "$TARGET/[Link]" "$TARGET/[Link]"
mv "$TARGET/[Link]" "$TARGET/[Link]"
rm "$TARGET/[Link]"
# ■■ Find files ■■
find /etc -name '*.conf' -type f 2>/dev/null | head -5
# ■■ Permissions ■■
chmod 755 [Link] # rwxr-xr-x
chmod u+x,go-w [Link] # symbolic mode
chown user:group [Link] # change owner
# ■■ Disk usage ■■
du -sh /var/log # human-readable size of directory
df -h # file system usage
Heredoc & Herestring
#!/bin/bash
# Heredoc — feed multiline string to a command
cat << 'HEREDOC'
This text is passed verbatim to cat.
No variable expansion when delimiter is quoted.
HEREDOC
name='Alice'
cat << HEREDOC
Hello, $name! # variable IS expanded here
Today: $(date) # command substitution works too
HEREDOC
# Indented heredoc (strip leading tabs with <<-)
cat <<- INDENTED
Line with leading tab stripped
Another indented line
INDENTED
# Herestring — single string
grep 'root' <<< 'root:x:0:0:root:/root:/bin/bash'
11. Regular Expressions & Text Processing
#!/bin/bash
# ■■ Regex match with [[ =~ ]] ■■
email='user@[Link]'
regex='^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if [[ $email =~ $regex ]]; then
echo "Valid email: $email"
echo "Username: ${BASH_REMATCH[0]}"
else
echo 'Invalid email'
fi
# ■■ grep ■■
grep -E '^root' /etc/passwd # extended regex
grep -c 'error' /var/log/syslog # count matches
grep -v 'debug' [Link] # invert match
grep -rn 'TODO' ./src/ # recursive + line numbers
# ■■ sed — stream editor ■■
echo 'Hello World' | sed 's/World/Bash/' # substitute
sed -i 's/foo/bar/g' [Link] # in-place, all
sed -n '5,10p' [Link] # print lines 5-10
sed '/^#/d' [Link] # delete comment lines
# ■■ awk — pattern scanning & processing ■■
awk '{print $1, $3}' [Link] # print cols 1 and 3
awk -F: '{print $1}' /etc/passwd # custom delimiter
awk '$3 > 1000 {print $1, $3}' /etc/passwd # conditional print
awk '{sum += $1} END {print "Sum:", sum}' [Link]
# ■■ tr — translate characters ■■
echo 'hello world' | tr 'a-z' 'A-Z' # uppercase
echo 'a:b:c:d' | tr ':' '\n' # split on colon
echo 'hello world' | tr -s ' ' # squeeze spaces
12. Process Management & Signal Handling
#!/bin/bash
# ■■ Background jobs ■■
sleep 60 & # run in background
bg_pid=$! # capture PID
echo "Started sleep with PID $bg_pid"
jobs # list background jobs
wait $bg_pid # wait for specific job
wait # wait for ALL background jobs
# ■■ kill / killall ■■
kill $bg_pid # send SIGTERM (graceful)
kill -9 $bg_pid # send SIGKILL (force)
killall sleep # kill all 'sleep' processes
# ■■ trap — catch signals ■■
cleanup() {
echo 'Caught signal — cleaning up...'
rm -f /tmp/[Link]
exit 1
}
trap cleanup SIGINT SIGTERM # Ctrl+C or kill
trap 'echo "Script finished"' EXIT # always runs on exit
echo $$ > /tmp/[Link]
echo 'Working... press Ctrl+C to interrupt'
sleep 30
# ■■ Subshells ■■
(cd /tmp && ls) # runs in subshell — cwd unchanged
echo "Still in: $(pwd)"
# ■■ Process substitution ■■
diff <(ls dir1) <(ls dir2)
while read -r line; do
echo "$line"
done < <(find /etc -name '*.conf' 2>/dev/null)
13. Advanced Topics & Best Practices
Strict Mode — Fail Fast
#!/bin/bash
set -euo pipefail
# -e exit immediately on error
# -u treat unset variables as errors
# -o pipefail pipeline fails if any command fails
IFS=$'\n\t' # safer word splitting
# Use || true to allow a command to fail without exiting
rm /tmp/optional_file 2>/dev/null || true
Logging & Debugging
#!/bin/bash
# ■■ Logging functions ■■
LOG_FILE='/var/log/[Link]'
log() { echo "[INFO] $(date '+%F %T') $*" | tee -a "$LOG_FILE"; }
warn() { echo "[WARN] $(date '+%F %T') $*" | tee -a "$LOG_FILE" >&2; }
error(){ echo "[ERROR] $(date '+%F %T') $*" | tee -a "$LOG_FILE" >&2; }
log 'Script started'
warn 'This is a warning'
error 'Something went wrong'
# ■■ Debug mode ■■
set -x # print each command before executing
echo 'debug on'
set +x # turn off debug
# Run entire script in debug: bash -x [Link]
# Debug specific section: PS4='+(${BASH_SOURCE}:${LINENO}): '
Error Handling Patterns
#!/bin/bash
set -euo pipefail
# ■■ die function ■■
die() { echo "ERROR: $*" >&2; exit 1; }
# ■■ Validate inputs ■■
[[ $# -eq 2 ]] || die 'Usage: [Link] <src> <dst>'
[[ -f $1 ]] || die "Source file not found: $1"
[[ -d $(dirname $2) ]]|| die "Destination dir missing"
# ■■ Temp files cleaned on exit ■■
TMPFILE=$(mktemp /tmp/[Link])
trap 'rm -f "$TMPFILE"' EXIT
# ■■ Lockfile to prevent multiple instances ■■
LOCKFILE='/tmp/[Link]'
exec 9>"$LOCKFILE"
flock -n 9 || die 'Another instance is running'
trap 'flock -u 9; rm -f $LOCKFILE' EXIT
Best Practices Summary
Always use #!/bin/bash — Never rely on /bin/sh for Bash-specific features.
Quote your variables — Use "$var" and "${arr[@]}" to avoid word-splitting issues.
Use [[ ]] over [ ] — [[ ]] is a Bash keyword — safer, supports &&, ||, =~.
Enable strict mode — set -euo pipefail at the top of every production script.
Prefer local variables — Declare function variables local to avoid namespace pollution.
Validate all inputs — Check argument count, types, and file existence early.
Trap EXIT for cleanup — Use trap '...' EXIT to always clean up temp files/locks.
Log to stderr for errors — echo 'error msg' >&2 so it doesn't pollute stdout pipelines.
Use shellcheck — Run shellcheck [Link] to catch common bugs automatically.
Avoid parsing ls output — Use globs (for f in /tmp/*.txt) or find instead.
■ Congratulations! You now have a solid foundation in Bash scripting. Practice by automating real tasks:
backups, log rotation, system health checks, or build your own CLI tools. Happy scripting!