Functions, Arguments & Exit Codes

Linux · Lesson 29 of 32 8 min read Updated

The scripts so far ran from top to bottom with values written inside them. Real scripts take arguments from the command line, are split into functions that each do one job, report success or failure with an exit code, and stop safely when something goes wrong instead of carrying on and causing damage.

This lesson covers all four, and ends with a complete, reusable backup script that puts them together.

Functions

A function is a named group of commands you can call like a command. Define it before you use it:

Terminal
greet() {
    local name="$1"
    local city="${2:-Pune}"
    echo "Namaste $name from $city"
}
greet Meera Kochi
greet Rohan
Output
Namaste Meera from Kochi
Namaste Rohan from Pune

Inside a function, $1, $2 and so on are the function's own arguments. local makes a variable belong to the function only; without it, every variable is global and can silently overwrite one with the same name elsewhere in the script. Make local a habit.

Bash functions do not return values the way Python functions do. To give back data, print it and capture the output with $( ). The return statement only sets a success or failure code, which you can test with if:

Terminal
add_gst() {
    local amount=$1
    echo $(( amount * 118 / 100 ))
}
is_weekend() {
    local day
    day=$(date -d "$1" +%u)
    (( day >= 6 ))
}
echo "With GST: Rs $(add_gst 1000)"
for d in 2026-10-30 2026-10-31; do
    if is_weekend "$d"; then echo "$d: weekend"; else echo "$d: weekday"; fi
done
Output
With GST: Rs 1180
2026-10-30: weekday
2026-10-31: weekend

is_weekend returns the status of its last command, (( day >= 6 )), which succeeds for Saturday (6) and Sunday (7). Functions named like questions and used in if make scripts read almost like English.

Script arguments

Arguments typed after a script's name arrive in special variables:

VariableHolds
$0The script's name
$1 ... $9, ${10}Individual arguments
$#The number of arguments
"$@"All arguments, each as a separate word (use this one)
$*All arguments joined into one string
Terminal
#!/usr/bin/env bash
echo "First: $1, second: $2"
echo "Count: $#"
for a in "$@"; do echo "arg: [$a]"; done
shift
echo "After shift: $*"
Terminal
./args.sh shop "Navi Mumbai" 2026
Output
First: shop, second: Navi Mumbai
Count: 3
arg: [shop]
arg: [Navi Mumbai]
arg: [2026]
After shift: Navi Mumbai 2026

Quoting "Navi Mumbai" on the command line keeps it as one argument, and "$@" keeps it as one inside the script. shift drops $1 and moves the rest down, which is handy for processing arguments in a loop. For options such as -v or -o file, Bash has the getopts builtin; help getopts shows how it works.

Exit codes

Every command finishes with an exit code (also called exit status): a number from 0 to 255. 0 means success; anything else means some kind of failure, and many commands document what each number means. The special variable $? holds the exit code of the last command:

Terminal
ls /nope 2> /dev/null
echo "ls exit code: $?"
grep -q Pune orders.csv; echo "found: $?"
grep -q Delhi orders.csv; echo "not found: $?"
Output
ls exit code: 2
found: 0
not found: 1

This is what if, && and || look at. mkdir -p backups && cd backups runs cd only if mkdir succeeded; cd /srv/app || exit 1 stops the script if the folder is missing. Your own scripts should end with exit 0 on success (the default) and exit 1 or another non-zero number on failure, so that cron, systemd and other scripts can tell what happened. Error messages belong on stderr: echo "Error: file not found" >&2.

Safer scripts: set -euo pipefail

By default, Bash keeps going after a command fails. In a deploy script, that can mean "copying failed, but delete the old version anyway". Three options make scripts stop at the first sign of trouble:

  • set -e: exit as soon as a command fails (with some exceptions, such as commands tested by if or used with &&/||).
  • set -u: treat use of an unset variable as an error instead of silently using an empty string.
  • set -o pipefail: a pipeline fails if any command in it fails, not only the last one.
Terminal
#!/usr/bin/env bash
set -euo pipefail
echo "start"
cp /nope/file.txt /tmp/
echo "never printed"
Output
start
cp: cannot stat '/nope/file.txt': No such file or directory

The script stopped with exit code 1 instead of running the rest. set -u catches a classic disaster: with a typo such as rm -rf "$taget_dir/"*, an empty variable would turn the path into /. With -u, Bash stops with "taget_dir: unbound variable" before anything is deleted.

Cleaning up with trap

trap runs a command when the script exits or receives a signal. The EXIT trap runs however the script ends, on success, on error with set -e, or after Ctrl+C, which makes it ideal for deleting temporary files:

Terminal
#!/usr/bin/env bash
set -euo pipefail
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"; echo "cleaned up $tmp"' EXIT
echo "working in $tmp"
false
echo "not reached"
Output
working in /tmp/tmp.gy5Jo8yFek
cleaned up /tmp/tmp.gy5Jo8yFek

mktemp -d creates a uniquely named temporary folder, so two copies of the script never collide.

Putting it together: a backup script

This script archives a folder, keeps only the newest copies, checks its arguments, logs what it does and stops on errors:

Terminal
#!/usr/bin/env bash
# backup.sh: archive a folder into a backup directory, keeping the newest N copies
set -euo pipefail

usage() {
    echo "Usage: $0 SOURCE_DIR DEST_DIR [KEEP]" >&2
    exit 2
}

log() {
    echo "[$(date +%T)] $*"
}

(( $# >= 2 )) || usage
src="$1"
dest="$2"
keep="${3:-7}"

[[ -d $src ]] || { echo "Error: $src is not a directory" >&2; exit 1; }
mkdir -p "$dest"

name="$(basename "$src")-$(date +%F-%H%M).tar.gz"
log "Archiving $src to $dest/$name"
tar -czf "$dest/$name" -C "$(dirname "$src")" "$(basename "$src")"

ls -1t "$dest"/*.tar.gz | tail -n +$(( keep + 1 )) | while read -r f; do
    log "Removing old backup $f"
    rm -- "$f"
done
log "Done. $(ls -1 "$dest"/*.tar.gz | wc -l) backup(s) kept."
Terminal
./backup.sh
./backup.sh ~/projects/shop-site /srv/backups 3
Output
Usage: ./backup.sh SOURCE_DIR DEST_DIR [KEEP]
[02:30:01] Archiving /home/priya/projects/shop-site to /srv/backups/shop-site-2026-10-30-0230.tar.gz
[02:30:02] Removing old backup /srv/backups/shop-site-2026-10-26-0230.tar.gz
[02:30:02] Done. 3 backup(s) kept.

ls -1t lists newest first, tail -n +4 (when keeping 3) selects everything after the third, and rm -- makes sure a name starting with - is never read as an option. Schedule it with the timer from Scheduling: cron & systemd Timers.

Common mistakes

  • Forgetting local. A function quietly changes a global variable with the same name.
  • Using $* or unquoted $@. Arguments with spaces break apart; use "$@".
  • Trying to return a string. return only takes a number 0 to 255; print data and capture it with $( ).
  • Checking $? too late. Any command in between, even echo, replaces it.
  • Scripts without set -euo pipefail. Failures go unnoticed and later steps run on bad data.

Key points

  • Define functions with name() { ...; }; use local for their variables and print results for $( ) to capture.
  • $1, $2 ... are arguments, $# is their count and "$@" is all of them safely; shift moves them along.
  • Exit code 0 means success; $? holds the last one; && and || act on it; end scripts with a meaningful exit.
  • Start scripts with set -euo pipefail to stop on errors, unset variables and failed pipelines.
  • trap '...' EXIT runs clean-up code however the script ends.

Exercise

Write a script check-sites.sh that takes any number of URLs as arguments. It must print a usage message and exit with code 2 if none are given. For each URL, a function check should use curl -s -o /dev/null -w '%{http_code}' URL to get the status code and print UP for 200, otherwise DOWN. The script must exit with code 1 if any site was down, and 0 otherwise.

Show solution
Terminal
#!/usr/bin/env bash
set -euo pipefail

check() {
    local url="$1" code
    code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$url" || true)
    if [[ $code == 200 ]]; then
        echo "UP    $url"
    else
        echo "DOWN  $url (status $code)"
        return 1
    fi
}

(( $# > 0 )) || { echo "Usage: $0 URL..." >&2; exit 2; }
failed=0
for url in "$@"; do
    check "$url" || failed=1
done
exit "$failed"
Terminal
./check-sites.sh https://shop.example.in https://shop.example.in/missing
echo "exit code: $?"
Output
UP    https://shop.example.in
DOWN  https://shop.example.in/missing (status 404)
exit code: 1

|| true stops set -e from ending the script when curl itself fails (for example on a timeout, where the code is 000). check ... || failed=1 records a failure without stopping the loop, and the final exit reports the overall result to cron or another script.

Quick Quiz

1. How does a Bash function usually give back a text result?

2. Which expands to all script arguments, keeping each one separate even if it contains spaces?

3. What exit code means success?

4. What does set -u do?

Learn Linux with a mentor

Free lessons get you started. Our Ethical Hacking & Cyber Security training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call