Advanced Bash Techniques

Once you are comfortable with variables, loops, and conditionals, it is time to level up. This page covers the features that separate quick throwaway scripts from robust, production-grade Bash programs: associative arrays, functions, traps, strict mode, process substitution, here strings, and debugging. Mastering these techniques will let you write scripts that are reliable, maintainable, and easy to troubleshoot.

Associative Arrays

While indexed arrays map integer indices to values, associative arrays map arbitrary string keys. Declare them with declare -A.

declare -A config
config[host]="db.example.com"
config[port]="5432"
config[user]="admin"

echo "Connecting to ${config[host]}:${config[port]} as ${config[user]}"

# Iterate over keys
for key in "${!config[@]}"; do
    echo "$key = ${config[$key]}"
done

# Check if a key exists
if [[ -v config[host] ]]; then
    echo "Host is set."
fi

# Get number of entries
echo "Config has ${#config[@]} entries."

# Delete a key
unset 'config[port]'

Associative arrays require Bash 4.0 or later. On macOS, the system Bash is 3.2 -- install a newer version via Homebrew (brew install bash) if you need this feature. You can check your version with bash --version.

A practical use case is building a lookup table:

declare -A status_codes
status_codes[200]="OK"
status_codes[404]="Not Found"
status_codes[500]="Internal Server Error"

code=404
echo "HTTP $code: ${status_codes[$code]:-Unknown}"

Functions

Functions let you organize code into reusable, testable units. Bash supports two syntaxes; the name() {} form is more portable.

greet() {
    local name="$1"
    local greeting="${2:-Hello}"
    echo "${greeting}, ${name}!"
}

greet "Alice"             # Hello, Alice!
greet "Bob" "Good morning" # Good morning, Bob!

Key Points About Functions

  • Use local for variables to avoid polluting the global scope. Without local, variables set inside a function are visible everywhere in the script.
  • Functions receive their own $1, $2, $@, and $#.
  • Use return to set an exit code (0-255). It does not return a string; use echo and command substitution for that.
is_even() {
    local num="$1"
    if (( num % 2 == 0 )); then
        return 0  # success / true
    else
        return 1  # failure / false
    fi
}

if is_even 42; then
    echo "42 is even"
fi

# Returning a string value
get_hostname() {
    hostname -f
}

my_host=$(get_hostname)
echo "Running on $my_host"

Argument Validation in Functions

Good functions validate their inputs:

create_user() {
    if [[ $# -lt 2 ]]; then
        echo "Usage: create_user <username> <email>" >&2
        return 1
    fi
    local username="$1"
    local email="$2"
    echo "Creating user $username with email $email"
    # ... actual logic ...
}

Trap: Handling Signals and Cleanup

The trap builtin lets you run commands when your script receives signals or exits. This is essential for cleaning up temporary files, releasing locks, and reporting errors.

#!/usr/bin/env bash
set -euo pipefail

TEMP_FILE=$(mktemp)

cleanup() {
    rm -f "$TEMP_FILE"
    echo "Cleaned up $TEMP_FILE" >&2
}
trap cleanup EXIT

on_error() {
    echo "Error on line $1" >&2
}
trap 'on_error $LINENO' ERR

# Work with the temp file
echo "data" > "$TEMP_FILE"
cat "$TEMP_FILE"
# cleanup runs automatically on EXIT, even if the script fails

Common Signals

Signal When It Fires
EXIT Script exits (normally or due to error)
ERR A command returns a non-zero exit code (with set -e)
INT User presses Ctrl-C
TERM Process receives a termination signal

You can trap multiple signals: trap cleanup EXIT INT TERM.

A more elaborate trap pattern for scripts that create multiple temporary resources:

declare -a CLEANUP_FILES=()

register_temp() {
    CLEANUP_FILES+=("$1")
}

cleanup_all() {
    for f in "${CLEANUP_FILES[@]}"; do
        rm -f "$f"
    done
}
trap cleanup_all EXIT

tmpfile1=$(mktemp)
register_temp "$tmpfile1"
tmpfile2=$(mktemp)
register_temp "$tmpfile2"

Strict Mode: set -euo pipefail

This trio of options catches entire categories of bugs. Put it at the top of every script.

#!/usr/bin/env bash
set -euo pipefail
  • -e: Exit immediately if any command fails (non-zero exit code).
  • -u: Treat unset variables as errors instead of expanding to empty strings.
  • -o pipefail: A pipeline fails if any command in it fails, not just the last one.
set -euo pipefail

# Without -u, this would silently expand to ""
echo "$UNDEFINED_VAR"   # Error: UNDEFINED_VAR: unbound variable

# Without -o pipefail, this would succeed because 'wc' succeeds
cat /nonexistent 2>/dev/null | wc -l   # Pipeline fails because cat fails

There are edge cases to be aware of. Commands in if conditions, ||, and && chains are exempt from -e. If you need a command to be allowed to fail, use || true:

# Allow this command to fail without exiting
grep -q "optional_feature" config.txt || true

Subshells

Parentheses create a subshell -- a child process with its own copy of the environment. Changes to variables inside a subshell do not affect the parent.

x=10
(
    x=20
    echo "Inside subshell: x=$x"   # 20
)
echo "Outside subshell: x=$x"      # 10

# Useful for temporary directory changes
(
    cd /tmp
    echo "Working in $(pwd)"   # /tmp
)
echo "Still in $(pwd)"         # original directory

Subshells are also useful for grouping commands whose combined output you want to redirect:

(
    echo "Header"
    cat data.txt
    echo "Footer"
) > output.txt

Process Substitution

Process substitution lets you use the output of a command as if it were a file. The syntax is <(command) for input and >(command) for output.

# Compare the output of two commands side by side
diff <(sort file1.txt) <(sort file2.txt)

# Feed command output to a program that expects a file argument
paste <(cut -f1 data.tsv) <(cut -f3 data.tsv)

# Use with while-read loops to avoid subshell variable scoping issues
count=0
while IFS= read -r line; do
    ((count++))
done < <(find /var/log -name "*.log" -type f)
echo "Found $count log files"

The < <(cmd) pattern in the last example is especially important: a regular pipe (cmd | while) runs the while in a subshell, so $count would reset to zero after the loop. Process substitution avoids this problem entirely.

Here Strings

A here string (<<<) feeds a string directly to a command's standard input, saving you from writing echo "..." | cmd.

# Instead of: echo "hello world" | tr ' ' '\n'
tr ' ' '\n' <<< "hello world"

# Read fields from a string
IFS=: read -r user _ uid _ <<< "root:x:0:0:root:/root:/bin/bash"
echo "User: $user, UID: $uid"

# Useful with while-read
while IFS=, read -r name age city; do
    echo "$name is $age years old, lives in $city"
done <<< "Alice,30,Paris
Bob,25,Berlin
Carol,35,Tokyo"

Debugging

When a script misbehaves, these techniques help you find the problem quickly.

set -x (Trace Mode)

Prints every command before it executes, with variables expanded.

#!/usr/bin/env bash
set -x

name="debug"
echo "Hello, $name"
# Output:
# + name=debug
# + echo 'Hello, debug'
# Hello, debug

Custom PS4

The PS4 variable controls the trace prefix. Set it to include the script name, function, and line number for complex scripts.

export PS4='+${BASH_SOURCE}:${FUNCNAME[0]:-main}:${LINENO}: '
set -x

my_func() {
    echo "inside function"
}
my_func
# Output:
# +./script.sh:main:8: my_func
# +./script.sh:my_func:5: echo 'inside function'

Selective Debugging

You do not have to trace the entire script. Enable and disable tracing around the suspicious section.

# Only debug this block
set -x
problematic_function "$arg1" "$arg2"
set +x

bashdb and ShellCheck

For more structured debugging, consider bashdb (a Bash debugger with breakpoints and stepping) and shellcheck (a static analysis tool that catches common mistakes before you even run the script).

# Install shellcheck and run it
# brew install shellcheck  (macOS)
# apt install shellcheck   (Debian/Ubuntu)
shellcheck myscript.sh

ShellCheck is particularly valuable because it catches quoting bugs, unused variables, deprecated syntax, and platform-specific issues. Integrate it into your editor or CI pipeline for maximum benefit.

Putting It All Together

Here is a function-based script that demonstrates several advanced features in combination:

#!/usr/bin/env bash
set -euo pipefail
export PS4='+${BASH_SOURCE}:${LINENO}: '

declare -A STATS

count_lines() {
    local file="$1"
    local lines
    lines=$(wc -l < "$file")
    STATS["$file"]=$lines
}

main() {
    trap 'echo "Error on line $LINENO" >&2' ERR

    for f in "$@"; do
        [[ -f "$f" ]] && count_lines "$f"
    done

    for key in "${!STATS[@]}"; do
        printf "%-40s %d lines\n" "$key" "${STATS[$key]}"
    done
}

main "$@"

Next Steps

With these advanced techniques, you can write Bash scripts that are robust, maintainable, and debuggable. For foundational topics, revisit Bash Fundamentals. To learn about scheduling automated scripts, see Task Automation. Return to the Shell Scripting hub for the full topic list.