Skip to content
elephantoo

Functions & robust scripts

Lesson 25 of 31 18 min read

Functions, local variables, arrays, set -euo pipefail, trap, getopts and ShellCheck.


Short scripts can get away with anything. Scripts that run unattended (from cron, CI or deployments) need to be organised and fail safely: stop at the first error, clean up after themselves, and explain what went wrong. This lesson covers functions, local variables, arrays, strict mode, traps, option parsing with getopts, debugging, and ShellCheck.

Functions#

Terminal
greet() {
  echo "Hello, $1!"
}
greet "Ada"
greet "Linus"
Output
Hello, Ada!
Hello, Linus!

A function is a named group of commands. It must be defined before it's called, and it receives arguments just like a script: $1, $2, $#, "$@". ($0 is still the script name.) The function greet { ... } form also exists, but name() { ... } is the portable style.

Returning values

return N sets the function's exit status (0 = success). To return data, print it and capture the output:

Terminal
is_even() {
  (( $1 % 2 == 0 ))          # the exit status of the last command is the return value
}
to_slug() {
  local text="$1"
  text="${text,,}"            # lowercase
  text="${text// /-}"         # spaces -> dashes
  printf '%s\n' "$text"
}
if is_even 42; then echo "42 is even"; fi
is_even 7 || echo "7 is odd"
slug=$(to_slug "My First Blog Post")
echo "slug=$slug"
Output
42 is even
7 is odd
slug=my-first-blog-post

Local variables

Variables in bash are global by default, even inside functions. Use local to keep them private:

Terminal
counter=10
bump() {
  local counter=0
  counter=$((counter + 1))
  echo "inside: $counter"
}
leaky() { result="I escaped!"; }
bump
echo "outside: $counter"
leaky
echo "result=$result"
Output
inside: 1
outside: 10
result=I escaped!

Make every function variable local unless you deliberately want to share it.

Arrays#

Bash has indexed arrays and (since bash 4) associative arrays:

Terminal
servers=(web1 web2 "db primary")
servers+=(cache1)
echo "count: ${#servers[@]}"
echo "first: ${servers[0]}, last: ${servers[-1]}"
for s in "${servers[@]}"; do echo " - $s"; done

declare -A port=([http]=80 [https]=443 [ssh]=22)
port[mysql]=3306
for name in "${!port[@]}"; do echo "$name=${port[$name]}"; done | sort
Output
count: 4
first: web1, last: cache1
 - web1
 - web2
 - db primary
 - cache1
http=80
https=443
mysql=3306
ssh=22
  • "${arr[@]}" (quoted) expands to every element as a separate word. That's the array version of "$@".
  • ${#arr[@]} is the length and ${!arr[@]} gives the indexes or keys.
  • Arrays are perfect for building command lines safely: opts=(-avz --delete); rsync "${opts[@]}" src/ dest/.

Strict mode: set -euo pipefail#

By default bash keeps going after a command fails, which is dangerous:

Terminal
cat > unsafe.sh <<'EOF'
#!/usr/bin/env bash
cd /does/not/exist
echo "Still running in $PWD... imagine 'rm -rf *' here"
EOF
bash unsafe.sh
Output
unsafe.sh: line 2: cd: /does/not/exist: No such file or directory
Still running in /home/ada... imagine 'rm -rf *' here

Start serious scripts with these lines:

Terminal
cat > safe.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cd /does/not/exist
echo "never printed"
EOF
bash safe.sh; echo "exit status: $?"
Output
safe.sh: line 3: cd: /does/not/exist: No such file or directory
exit status: 1
OptionEffect
set -e (errexit)exit as soon as a command fails
set -u (nounset)using an unset variable is an error, not an empty string
set -o pipefaila pipeline fails if any command in it fails
Terminal
bash -c 'set -u; echo "Deleting $TARGET_DIR/"' ; echo "status: $?"
bash -c 'false | true; echo "without pipefail: $?"'
bash -c 'set -o pipefail; false | true; echo "with pipefail: $?"'
Output
bash: line 1: TARGET_DIR: unbound variable
status: 127
without pipefail: 0
with pipefail: 1

Know the gotchas of set -e:

  • Commands in if, while, &&/|| chains are allowed to fail; that's how conditions work.
  • ((count++)) returns status 1 when count was 0, which exits the script. Use count=$((count + 1)) or ((count++)) || true.
  • grep returns 1 when it finds nothing. If that's OK, write grep pattern file || true.
  • Use ${VAR:-} to read an optional variable under set -u.

trap: cleanup and error reporting#

trap runs a command when the script receives a signal or exits:

Terminal
cat > with-trap.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
tmpdir=$(mktemp -d)
cleanup() {
  rm -rf "$tmpdir"
  echo "cleaned up temp dir"
}
trap cleanup EXIT
trap 'echo "error on line $LINENO" >&2' ERR

echo "working in a temp dir"
echo "data" > "$tmpdir/file"
false                      # simulate a failure
echo "not reached"
EOF
bash with-trap.sh; echo "exit status: $?"
Output
working in a temp dir
error on line 13
cleaned up temp dir
exit status: 1
  • EXIT runs on every exit: success, error, or exit N. It's ideal for deleting temp files, removing lock files and stopping helper processes.
  • ERR runs when a command fails (with set -e). It's useful for logging where it failed.
  • INT TERM catch Ctrl+C and kill: trap 'echo interrupted; exit 130' INT TERM.
  • mktemp / mktemp -d create unique temp files and directories safely. Never hard-code /tmp/myfile.

Parsing options with getopts#

For real command-line tools, accept options like -v or -o file:

Terminal
cat > report.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

usage() {
  echo "Usage: $0 [-v] [-n LINES] FILE..." >&2
  exit 2
}

verbose=0
lines=3
while getopts ":vn:h" opt; do
  case "$opt" in
    v) verbose=1 ;;
    n) lines="$OPTARG" ;;
    h) usage ;;
    :) echo "Option -$OPTARG needs a value" >&2; usage ;;
    ?) echo "Unknown option -$OPTARG" >&2; usage ;;
  esac
done
shift $((OPTIND - 1))          # drop parsed options; "$@" = remaining args
(( $# > 0 )) || usage

for f in "$@"; do
  (( verbose )) && echo "== $f ($(wc -l < "$f") lines)"
  head -n "$lines" "$f"
done
EOF
chmod +x report.sh
seq 1 10 > numbers.txt
./report.sh -v -n 2 numbers.txt
./report.sh -x numbers.txt; echo "exit: $?"
./report.sh -n; echo "exit: $?"
Output
== numbers.txt (10 lines)
1
2
Unknown option -x
Usage: ./report.sh [-v] [-n LINES] FILE...
exit: 2
Option -n needs a value
Usage: ./report.sh [-v] [-n LINES] FILE...
exit: 2

In the getopts string ":vn:h", a letter followed by : takes a value (-n 2), and the leading : lets you handle errors yourself. getopts handles short options only. For long options (--verbose), loop over the arguments with case "$1" in --verbose) ...; esac; shift.

Debugging#

Terminal
bash -x report.sh -n 1 numbers.txt 2>&1 | head -6
Output
+ set -euo pipefail
+ verbose=0
+ lines=3
+ getopts :vn:h opt
+ case "$opt" in
+ lines=1

bash -x (or set -x in the script, with set +x to turn it off) prints every command after expansion, prefixed with +. It's the fastest way to see what a script actually ran. bash -n script.sh checks the syntax without running anything.

ShellCheck: a linter for shell scripts#

ShellCheck catches quoting bugs, typos and portability issues before they bite. Install it with sudo apt install shellcheck or sudo dnf install ShellCheck, or use the editor plugins for VS Code and others:

Terminal
cat > copy.sh <<'EOF'
#!/usr/bin/env bash
cp $1 $2
EOF
shellcheck copy.sh
Output
In copy.sh line 2:
cp $1 $2
   ^-- SC2086 (info): Double quote to prevent globbing and word splitting.
      ^-- SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
cp "$1" "$2"

For more information:
  https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...

Run ShellCheck on every script you commit; many teams enforce it in CI.

A robust script template#

Terminal
#!/usr/bin/env bash
# deploy.sh: what this script does, in one line
set -euo pipefail

readonly SCRIPT_NAME="${0##*/}"
log()  { printf '%s [%s] %s\n' "$(date +%T)" "$SCRIPT_NAME" "$*" >&2; }
die()  { log "ERROR: $*"; exit 1; }

cleanup() { rm -rf "${tmpdir:-}"; }
trap cleanup EXIT

main() {
  [[ $# -ge 1 ]] || die "usage: $SCRIPT_NAME ENVIRONMENT"
  local env="$1"
  command -v rsync >/dev/null || die "rsync is not installed"
  tmpdir=$(mktemp -d)
  log "deploying to $env"
  # ... real work here ...
  log "done"
}

main "$@"

Wrapping the logic in main "$@" at the bottom means every function is defined before anything runs, and the script reads top-down like a table of contents.

💡 Know when to switch languages. If a script grows past a couple of hundred lines, needs complex data structures, JSON handling or real error handling, rewrite it in Python. Bash is brilliant glue, not a general-purpose language.

Common mistakes#

  • Forgetting local: functions silently overwrite globals.
  • Trying to return "string": return only takes numbers 0–255. Print and capture instead.
  • No strict mode: scripts keep running after cd or cp fails.
  • ((i++)) under set -e when i is 0, which exits the script unexpectedly.
  • Hard-coded temp paths like /tmp/out.txt (collisions, security). Use mktemp.
  • Unquoted "${array[@]}": elements with spaces split apart.

What's next#

Your scripts are robust enough to run unattended. Next you'll make them run on a schedule with cron and systemd timers.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What does set -euo pipefail do?

  2. 2.How does a bash function return a string to its caller?

  3. 3.What is trap cleanup EXIT used for?

Finished reading?

Mark this lesson complete to track your progress.