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
localfor variables to avoid polluting the global scope. Withoutlocal, variables set inside a function are visible everywhere in the script. - Functions receive their own
$1,$2,$@, and$#. - Use
returnto set an exit code (0-255). It does not return a string; useechoand 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.