Bash Variables, Arrays, and String Manipulation Guide
Master Bash variables, indexed and associative arrays, parameter expansion, and string manipulation techniques. Lesson 4 of the Linux & Bash Scripting course.

Bash's parameter expansion syntax is dense but powerful. A single expression can provide a default value, strip a prefix, extract a substring, or transform case — without spawning a subprocess. Knowing these built-in operations means fewer calls to sed, awk, and cut in your scripts, and faster execution in tight loops.
Previous: Lesson 3 — Bash Scripting Fundamentals
Variable Scope and the local Keyword
By default, all Bash variables are global — even those assigned inside a function. Use local to prevent leaking state:
#!/usr/bin/env bash
counter=0
increment() {
local step="${1:-1}" # local, defaults to 1
counter=$(( counter + step ))
}
increment 5
echo "$counter" # 5 — modified the global counterThe local keyword only works inside functions. Any variable not declared local modifies the global scope.
Parameter Expansion Reference
Parameter expansion is the ${} syntax. It goes far beyond simple variable substitution:
Default Values
# Use default if unset or empty
host="${DB_HOST:-localhost}"
# Use default only if unset (not if empty)
host="${DB_HOST-localhost}"
# Assign default and store it
host="${DB_HOST:=localhost}"
echo "$DB_HOST" # now set to "localhost" if it was unset
# Error and exit if unset or empty
required="${REQUIRED_VAR:?'REQUIRED_VAR must be set'}"String Length
str="Hello, World"
echo "${#str}" # 12Substring Extraction
str="Hello, World"
echo "${str:7}" # World (offset 7 to end)
echo "${str:7:3}" # Wor (offset 7, length 3)
echo "${str: -5}" # World (last 5 characters)Prefix and Suffix Removal
filename="archive_2024_backup.tar.gz"
# Remove shortest prefix matching pattern
echo "${filename#*_}" # 2024_backup.tar.gz
# Remove longest prefix matching pattern
echo "${filename##*_}" # backup.tar.gz
# Remove shortest suffix matching pattern
echo "${filename%.*}" # archive_2024_backup.tar
# Remove longest suffix matching pattern
echo "${filename%%.*}" # archive_2024_backup
# Practical: extract directory and filename
path="/var/log/nginx/access.log"
echo "${path%/*}" # /var/log/nginx (dirname)
echo "${path##*/}" # access.log (basename)Find and Replace
str="the cat sat on the mat"
# Replace first occurrence
echo "${str/cat/dog}" # the dog sat on the mat
# Replace all occurrences
echo "${str//at/ot}" # the cot sot on the mot
# Replace prefix
echo "${str/#the/a}" # a cat sat on the mat
# Replace suffix
echo "${str/%mat/rug}" # the cat sat on the rugCase Conversion (Bash 4+)
str="Hello World"
echo "${str,,}" # hello world (all lowercase)
echo "${str^^}" # HELLO WORLD (all uppercase)
echo "${str^}" # Hello world (capitalise first character)
echo "${str,}" # hello World (lowercase first character)Indexed Arrays
#!/usr/bin/env bash
# Declare and populate
servers=("web01" "web02" "db01" "cache01")
# Access by index (zero-based)
echo "${servers[0]}" # web01
echo "${servers[-1]}" # cache01 (last element, Bash 4.3+)
# All elements
echo "${servers[@]}" # web01 web02 db01 cache01
# Number of elements
echo "${#servers[@]}" # 4
# Array slice (offset 1, length 2)
echo "${servers[@]:1:2}" # web02 db01
# Append an element
servers+=("lb01")
# Remove an element (leaves a gap — use unset)
unset 'servers[2]'
# All indices
echo "${!servers[@]}" # 0 1 3 4
# Iterate safely
for server in "${servers[@]}"; do
echo "Checking: $server"
donePopulating Arrays from Command Output
# Read command output into an array
mapfile -t log_files < <(find /var/log -name "*.log" -type f)
echo "Found ${#log_files[@]} log files"
for f in "${log_files[@]}"; do
echo " $f"
done
# Split a string on a delimiter into an array
IFS=',' read -ra parts <<< "alpha,beta,gamma,delta"
echo "${parts[1]}" # betaAssociative Arrays (Dictionaries)
Associative arrays require Bash 4+ and must be explicitly declared:
#!/usr/bin/env bash
# Declare an associative array
declare -A config
config["host"]="db.internal"
config["port"]="5432"
config["name"]="appdb"
config["user"]="appuser"
# Access by key
echo "Connecting to ${config[host]}:${config[port]}"
# All keys
echo "${!config[@]}"
# All values
echo "${config[@]}"
# Check if a key exists
if [[ -v config["host"] ]]; then
echo "host is configured"
fi
# Iterate over key-value pairs
for key in "${!config[@]}"; do
echo "${key} = ${config[$key]}"
doneBuilding a Frequency Map
declare -A counts
while IFS= read -r line; do
status=$(echo "$line" | awk '{print $9}')
(( counts["$status"]++ )) || true
done < /var/log/nginx/access.log
for status in $(echo "${!counts[@]}" | tr ' ' '\n' | sort); do
printf "%-6s %d\n" "$status" "${counts[$status]}"
doneString Operations Without Subprocesses
Avoiding echo "..." | sed and similar patterns keeps scripts fast — especially inside loops:
str=" Hello, World! "
# Trim leading whitespace (pure Bash)
trimmed="${str#"${str%%[! ]*}"}"
# Trim trailing whitespace
trimmed="${trimmed%"${trimmed##*[! ]}"}"
# Check if a string contains a substring
if [[ "$str" == *"World"* ]]; then
echo "Contains World"
fi
# Check prefix
if [[ "$str" == " Hello"* ]]; then
echo "Starts with Hello"
fi
# Check suffix
if [[ "$str" == *"!"* ]]; then
echo "Contains exclamation mark"
fi
# String length comparison
if (( ${#str} > 10 )); then
echo "String is longer than 10 characters"
fideclare — Typed Variables
# Integer (arithmetic operations enforced)
declare -i port=5432
port+=1
echo "$port" # 5433
# Readonly
declare -r API_VERSION="v2"
# Lowercase all assignments to the variable
declare -l username
username="ALICE"
echo "$username" # alice
# Uppercase all assignments
declare -u env_name
env_name="production"
echo "$env_name" # PRODUCTION
# Export (equivalent to export)
declare -x DATABASE_URL="postgresql://localhost/mydb"