LinuxBash Scripting

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.

TT
Daniel Brooks
5 min read
Bash Variables, Arrays, and String Manipulation Guide

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:

bash
#!/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 counter

The 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

bash
# 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

bash
str="Hello, World"
echo "${#str}"    # 12

Substring Extraction

bash
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

bash
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

bash
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 rug

Case Conversion (Bash 4+)

bash
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

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

Populating Arrays from Command Output

bash
# 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]}"   # beta

Associative Arrays (Dictionaries)

Associative arrays require Bash 4+ and must be explicitly declared:

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

Building a Frequency Map

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

String Operations Without Subprocesses

Avoiding echo "..." | sed and similar patterns keeps scripts fast — especially inside loops:

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

declare — Typed Variables

bash
# 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"