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

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 status

2. 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}"
done

3. 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"
fi

4. 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 esac

5. 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 success

8. 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: blue

Pro Tips

Common Errors (and the fix)

📋 Quick Reference

ConceptSyntaxNotes
Functionf() { … }$1 = first arg
Local varlocal v=…Stays inside the function
Capture outputr=$(f arg)Reads what f echoes
Array all${arr[@]}Quote it in loops
Array count${#arr[@]}Number of elements
Appendarr+=(x)Adds to the end
Modern test[[ -f f && -n s ]]==, =~, -z/-n, -f/-d
Casecase x in p) … ;; esac;; per clause, *) default
Strict modeset -euo pipefail-e exit, -u unset, pipefail
Cleanuptrap '…' EXITRuns on any exit
Debug traceset -x / set +xPrints 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!

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

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.

Related lessons