Advanced Bash Scripting
Reviewed & published by Brayan K
Go beyond the basics. In this lesson you'll write real-world bash: functions that return data and status, arrays, modern [[ ]] tests and case branching, "strict mode" safety with set -euo pipefail, cleanup with trap, debugging traces, and powerful parameter-expansion tricks.
Part of the free Command Line course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Write functions with local variables, capture their echoed output, and set exit status with return
- Use arrays: index elements, expand all with ${arr[@]}, count with ${#arr[@]}, and append
- Test with modern [[ ]] — ==, =~ regex, -z/-n, -f/-d — and branch cleanly with case
- Add safety with set -euo pipefail and guaranteed cleanup with trap ... EXIT
- Understand exit codes and $?, and debug scripts with set -x command tracing
- Edit values inline with parameter expansion: ${VAR:-default}, ${#VAR}, ${VAR%.txt}, ${VAR/old/new}
1. Functions: Arguments, Scope & Return
Define a function with name() { ... } and call it like any command. Inside, the arguments are $1, $2, and $@ for all of them — exactly like a script. Use local so a function's variables don't leak out. A function doesn't return data with return; instead it echoes its result (you capture it with result=$(myfunc arg)), and uses return N only to set an exit status (0–255).
#!/bin/bash
# A function packages commands under a name so you can reuse them.
# Define one with name() { ... } — then call it like a command.
# Inside a function the arguments are $1, $2, ... and $@ is all of them.
# 'local' keeps a variable private to the function (it won't leak out).
greet() {
local who=$1 # $1 = the function's first argument
echo "Hello, ${who}!"
}
# Functions "return" DATA by echoing it; you capture that with $( ).
# They also set an EXIT STATUS with 'return N' (0 = success, 1-255 = fail).
add() {
local sum=$(( $1 + $2 )) # add the first two arguments
echo "$sum" # echo the result so a caller can capture it
return 0 # 0 means "succeeded"
}
greet "Ada"
# Capture the echoed output with command substitution:
result=$(add 2 3)
echo "2 + 3 = ${result}"
echo "add exit status: $?" # $? holds the last return/exit status2. Arrays
An array holds many values under one name: arr=(apple banana cherry). Read one element by index with ${arr[0]} (arrays are 0-based), all elements with ${arr[@]}, and the count with ${#arr[@]}. Append with arr+=(date), and loop safely with for x in "${arr[@]}"; do ... done (the quotes keep elements with spaces intact).
#!/bin/bash
# An array holds many values under one name.
arr=(apple banana cherry)
# One element by index (arrays are 0-based):
echo "First: ${arr[0]}" # apple
# ALL elements — quote it so each stays a separate word:
echo "All: ${arr[@]}" # apple banana cherry
# How many elements? Use ${#arr[@]} (the # means "count/length"):
echo "Count: ${#arr[@]}" # 3
# Append a new element with += :
arr+=(date)
echo "After append: ${arr[@]}" # apple banana cherry date
# Loop over every element safely with "${arr[@]}":
for fruit in "${arr[@]}"; do
echo "- ${fruit}"
done3. Modern Tests with [[ ]]
[[ ]] is bash's modern conditional — safer than [ ] with strings, and it understands == (with glob pattern matching), =~ (regex), and &&/|| inside the brackets. Handy string tests: -z (empty), -n (non-empty); file tests: -f (regular file), -d (directory). The older [ ] still works and is more portable, but in bash scripts prefer [[ ]].
#!/bin/bash
# [[ ]] is the MODERN bash conditional. It is safer than [ ] with
# strings, lets you combine tests with && and ||, supports == pattern
# matching and =~ regex. (Use the older [ ] only for POSIX portability.)
name="admin"
file="/etc/hostname"
empty=""
# == does an EXACT match; with a glob on the right it pattern-matches:
if [[ "$name" == admin ]]; then
echo "exact match"
fi
if [[ "$name" == ad* ]]; then # right side is a pattern here
echo "starts with 'ad'"
fi
# =~ matches a regular expression:
if [[ "$name" =~ ^a.*n$ ]]; then
echo "regex matched"
fi
# -z = string is EMPTY, -n = string is NON-empty:
if [[ -z "$empty" ]]; then echo "empty is empty"; fi
if [[ -n "$name" ]]; then echo "name is set"; fi
# -f = regular file exists, -d = directory exists; combine with &&:
if [[ -f "$file" && -d "/etc" ]]; then
echo "file and dir both exist"
fi4. The case Statement
When you'd write a long if/elif/elif chain comparing one value against many options, a case statement is cleaner: case "$x" in pattern) ... ;; *) ... ;; esac. Each clause ends with ;;, patterns can use globs (and | to list several), and *) is the catch-all default. Close the whole thing with esac (that's "case" backwards).
#!/bin/bash
# 'case' is clean multi-way branching — nicer than a long if/elif chain.
# Each clause is pattern) commands ;; and *) is the default.
fruit="banana"
case "$fruit" in
apple)
echo "Apples are red or green."
;;
banana|plantain) # | lists several matching patterns
echo "Bananas are yellow."
;;
c*) # patterns may use globs
echo "Starts with c."
;;
*) # *) is the catch-all default
echo "Unknown fruit."
;;
esac # close the whole statement with esac5. Safety: set -euo pipefail
Add this near the top of any serious script — it's often called "bash strict mode." set -e exits the moment any command fails (so the script can't blunder on with bad state). set -u turns the use of an unset variable into an error (catching typos in names). set -o pipefail makes a pipeline fail if any stage fails, not just the last one — so a broken command in the middle can't hide.
#!/bin/bash
# Put this near the TOP of serious scripts — "unofficial strict mode":
set -euo pipefail
# -e exit immediately if ANY command returns non-zero
# -u treat use of an UNSET variable as an error
# -o pipefail a pipeline fails if ANY stage fails (not just the last)
# With -u, this line would abort the script because NAME is undefined:
# echo "$NAME"
# With -o pipefail, this whole pipeline reports failure because 'false'
# fails, even though the final 'wc -l' would succeed on its own:
# false | wc -l
name="Grace"
echo "Strict mode is on. Hello, ${name}."6. Cleanup with trap
trap registers a handler to run when a signal or special event fires. The classic use is guaranteed cleanup: trap 'rm -f "$tmpfile"' EXIT deletes a temp file whenever the script ends — success, error, or Ctrl-C. You can also catch interrupts explicitly with trap ... INT TERM. This is far more reliable than a delete line at the bottom, which is skipped if the script exits early.
#!/bin/bash
# 'trap' runs a handler when a signal or special event happens.
# The classic use: clean up a temp file no matter HOW the script ends.
tmpfile=$(mktemp) # create a temp file, capture its path
# EXIT fires whenever the script finishes (success, error, or Ctrl-C):
trap 'rm -f "$tmpfile"' EXIT
# You can also catch interrupts and termination requests explicitly:
trap 'echo "Interrupted!"; exit 130' INT TERM
echo "Working in temp file: ${tmpfile}"
echo "some data" > "$tmpfile"
echo "Wrote data; the trap will delete the temp file on exit."
# When the script reaches the end, the EXIT trap runs and removes tmpfile.7. Exit Codes & Debugging
Every script ends with an exit code: exit 0 means success, any non-zero means failure. $? holds the exit code of the last command, and functions report status with return. To debug, turn on a trace with set -x — bash prints each command (with its values expanded) as it runs — and set +x turns it back off. You can also trace a whole file with bash -x script.sh.
#!/bin/bash
# EXIT CODES: 'exit 0' = success, any non-zero = a failure. $? holds the
# exit code of the LAST command. Functions report status with 'return'.
check_positive() {
if [[ "$1" -gt 0 ]]; then
return 0 # success
else
return 1 # failure
fi
}
check_positive 5
echo "5 positive? exit status = $?" # 0
# DEBUGGING: 'set -x' traces each command (with its expanded values)
# as it runs; 'set +x' turns the trace back off.
set -x
greeting="hi"
echo "${greeting} there"
set +x
echo "done"
exit 0 # explicitly end with success8. Parameter Expansion Tricks
Parameter expansion edits a value as you read it, with no external tools. The big ones: ${VAR:-default} uses a default if VAR is unset/empty; ${VAR:=default} also assigns it; ${#VAR} is the string length; ${VAR%.txt} strips a suffix; ${VAR#prefix} strips a prefix; and ${VAR/old/new} replaces text. These replace whole pipelines of sed/cut calls.
#!/bin/bash
# Parameter expansion edits a variable's value AS you read it — no
# external tools needed. Every form below is pure bash.
# ${VAR:-default} -> use 'default' if VAR is unset/empty (VAR unchanged)
unset COLOR
echo "${COLOR:-blue}" # blue (COLOR is still unset afterward)
# ${VAR:=default} -> use 'default' AND assign it to VAR
echo "${SIZE:=large}" # large
echo "SIZE is now ${SIZE}" # large
# ${#VAR} -> length of the string in VAR
word="banana"
echo "length: ${#word}" # 6
# ${VAR%.txt} -> strip the suffix '.txt' from the END
file="report.txt"
echo "${file%.txt}" # report
# ${VAR#prefix} -> strip 'prefix' from the START
path="img_photo"
echo "${path#img_}" # photo
# ${VAR/old/new} -> replace the first 'old' with 'new'
echo "${word/an/AN}" # bANana🎯 Your Turn: finish the function
The function is almost done — fill in the blanks marked ___ using the # 👉 hints, then run it and check the Output panel matches.
#!/bin/bash
# 🎯 YOUR TURN — finish the function, then run it.
# 1) Make 'size' private to the function.
square() {
___ n=$1 # 👉 replace ___ with local
# 2) Echo the result so the caller can capture it.
echo $(( n * n ))
}
# 3) Capture the function's echoed output into a variable.
answer=___ # 👉 replace ___ with $(square 4)
echo "4 squared is ${answer}"
# ✅ Expected output:
# 4 squared is 16🎯 Your Turn: work with an array
Two blanks: get the element count, then expand every element in the loop. Then run it.
#!/bin/bash
# 🎯 YOUR TURN — fill in the array blanks, then run it.
colors=(red green blue)
# 1) Print how many colors there are.
echo "There are ___ colors" # 👉 replace ___ with ${#colors[@]}
# 2) Loop over EVERY color safely.
for c in "___"; do # 👉 replace ___ with ${colors[@]}
echo "color: $c"
done
# ✅ Expected output:
# There are 3 colors
# color: red
# color: green
# color: bluePro Tips
- 💡 Always quote "${arr[@]}" in loops. Unquoted, elements containing spaces get split into extra words — quoting keeps each element whole.
- 💡 Capture output, not status: a function's result comes from $(myfunc); return only sets $?. Don't try to read data from return.
- 💡 Adopt set -euo pipefail early in real scripts. It turns silent, late-night bugs into loud, immediate failures you can actually find.
- 💡 Trace with set -x when a script misbehaves — seeing each command with its expanded values is usually faster than scattering echo statements.
Common Errors (and the fix)
- Function "returns the wrong number" — you used return 100 expecting to read 100. return only sets an exit status (0–255). Echo the value and capture it with $(...) instead.
- Loop only sees one giant item — you wrote ${arr[*]} or forgot the quotes. Loop over "${arr[@]}" to get each element separately.
- "unbound variable" after adding set -u — you referenced a variable that may be unset. Give it a default: "${VAR:-}", or set it before use.
- case "syntax error: unexpected end of file" — a clause is missing its ;;, or you forgot to close with esac. Every clause ends with ;;.
- Temp file left behind on Ctrl-C — your cleanup was a plain line at the end, which got skipped. Move it into trap 'rm -f "$tmpfile"' EXIT so it always runs.
📋 Quick Reference
| Concept | Syntax | Notes |
|---|---|---|
| Function | f() { … } | $1 = first arg |
| Local var | local v=… | Stays inside the function |
| Capture output | r=$(f arg) | Reads what f echoes |
| Array all | ${arr[@]} | Quote it in loops |
| Array count | ${#arr[@]} | Number of elements |
| Append | arr+=(x) | Adds to the end |
| Modern test | [[ -f f && -n s ]] | ==, =~, -z/-n, -f/-d |
| Case | case x in p) … ;; esac | ;; per clause, *) default |
| Strict mode | set -euo pipefail | -e exit, -u unset, pipefail |
| Cleanup | trap '…' EXIT | Runs on any exit |
| Debug trace | set -x / set +x | Prints each command |
| Default value | ${VAR:-def} | Use def if unset/empty |
Mini-Challenge: safe backup-namer
No blanks this time — just a brief and an outline. Combine everything from this lesson: strict mode, an argument guard, a trap cleanup, parameter expansion to build the backup name, and a case for a friendly message. Check your output against the example in the comments.
#!/bin/bash
# 🎯 MINI-CHALLENGE: a safe backup-namer
# Save as backup.sh and run: bash backup.sh report.txt
#
# 1. Turn on strict mode at the top with set -euo pipefail.
# 2. Read the filename from $1. If $# is 0, print
# "Usage: backup.sh <file>" and exit 1.
# 3. Use a 'trap ... EXIT' to echo "Done." when the script finishes.
# 4. Strip any ".txt" suffix with parameter expansion (${name%.txt})
# and build a backup name like <base>.bak.
# 5. Use a case statement on the file's value to print a friendly
# message for *.txt, *.log, and a default *) for anything else.
#
# Hints: base=${1%.txt} | trap 'echo "Done."' EXIT
#
# ✅ Example: bash backup.sh report.txt -> backup name: report.bak
# That's a text file.
# Done.
# your code here🎉 Lesson Complete!
- ✅ Functions take args ($1…), keep vars private with local, echo results (capture with $(...)), and set status via return
- ✅ Arrays: ${arr[0]} one, ${arr[@]} all, ${#arr[@]} count, arr+=(x) append
- ✅ Prefer [[ ]] (==, =~, -z/-n, -f/-d) and branch many ways with case … esac
- ✅ Harden scripts with set -euo pipefail and guarantee cleanup with trap '…' EXIT
- ✅ exit 0/$? track success; debug with set -x; reshape values with parameter expansion
- 🏆 You've finished the Command Line course! You can now read files, navigate, pipe, and write robust bash scripts — go automate something.
Practice quiz
How do you declare a variable that stays private to a function?
- local var=value
- let var=value
- scope var=value
- private var=value
Answer: local var=value. Inside a function, 'local var=value' keeps the variable from leaking into the rest of the script.
Inside a function, how do you refer to its first argument?
- $first
- ${1st}
- $1
- $arg1
Answer: $1. A function receives positional arguments just like a script: $1 is the first, $2 the second, and so on.
How do you expand ALL elements of an array named arr?
- ${arr}
- $arr[*]
- ${arr[@]}
- @arr
Answer: ${arr[@]}. ${arr[@]} expands to every element; quoted as "${arr[@]}" each element stays a separate word.
How do you get the number of elements in array arr?
- ${arr.length}
- ${#arr}
- $arr.count
- ${#arr[@]}
Answer: ${#arr[@]}. ${#arr[@]} gives the element count. Note ${#arr} would give the length of the FIRST element instead.
What does set -e do?
- Erase the environment
- Exit the script immediately if a command fails
- Echo every command
- Enable extended globbing
Answer: Exit the script immediately if a command fails. set -e (errexit) stops the script the moment any command returns a non-zero status.
What does set -u do?
- Update the PATH
- Undo the last command
- Unset all variables
- Treat unset variables as an error
Answer: Treat unset variables as an error. set -u (nounset) makes referencing an undefined variable an error instead of silently using an empty value.
What does pipefail do?
- Make a pipeline fail if any command in it fails
- Print the pipeline
- Buffer the pipe output
- Disable all pipes
Answer: Make a pipeline fail if any command in it fails. Normally a pipeline's status is that of the LAST command; set -o pipefail makes it fail if ANY stage fails.
What does 'trap cleanup EXIT' do?
- Trap network exits only
- Run the handler when the script exits
- Delete the cleanup function
- Ignore the EXIT signal
Answer: Run the handler when the script exits. trap registers a handler that runs on the named event; EXIT fires whenever the script terminates, perfect for cleanup.
What does ${VAR:-default} do?
- Always assign 'default' to VAR
- Delete VAR
- Compare VAR to 'default'
- Use 'default' if VAR is unset or empty
Answer: Use 'default' if VAR is unset or empty. ${VAR:-default} substitutes 'default' when VAR is unset or empty, WITHOUT changing VAR. Use := to also assign it.
What ends each clause in a case statement?
- done
- esac
- ;;
- fi
Answer: ;;. Each case clause ends with ;; and the whole statement is closed with esac.
Continue this course
- Previous: Networking Commands
- Quick reference: Command Line cheat sheet
Frequently asked questions
How does a bash function 'return' a value like in other languages?
It doesn't return data via 'return' — that only sets an exit STATUS (0-255). To pass data back, the function echoes it and the caller captures the output with command substitution: result=$(myfunc arg). Think of 'return' as success/failure, and echo as the actual result.
What's the difference between ${arr[@]} and ${#arr[@]}?
${arr[@]} expands to ALL the elements of the array (the values themselves), while ${#arr[@]} expands to the NUMBER of elements (a count). The leading # means 'length/count'. Always quote "${arr[@]}" in loops so elements containing spaces stay intact.
When should I use [[ ]] instead of [ ]?
In bash, prefer [[ ]] for almost everything: it is safer with empty or unset variables, supports == pattern matching and =~ regex, and lets you use && and || inside the brackets. Use the older [ ] only when you need strict POSIX portability across non-bash shells.
What exactly does set -euo pipefail protect me from?
Three classes of silent bugs. -e (errexit) stops the script the instant a command fails instead of charging on with bad state. -u (nounset) catches typos in variable names by erroring on unset variables. -o pipefail makes a pipeline fail if ANY stage fails, not just the last — so a broken middle command can't be hidden.
Why use trap instead of just deleting my temp file at the end?
Code at the end of a script only runs if execution reaches it. If the script errors out (especially under set -e) or the user hits Ctrl-C, your cleanup line is skipped and the temp file is left behind. A 'trap ... EXIT' handler runs no matter HOW the script ends, so cleanup is guaranteed.