Functions, Arguments & Exit Codes
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:
greet() {
local name="$1"
local city="${2:-Pune}"
echo "Namaste $name from $city"
}
greet Meera Kochi
greet RohanNamaste 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:
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
doneWith 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:
| Variable | Holds |
|---|---|
$0 | The 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 |
#!/usr/bin/env bash
echo "First: $1, second: $2"
echo "Count: $#"
for a in "$@"; do echo "arg: [$a]"; done
shift
echo "After shift: $*"./args.sh shop "Navi Mumbai" 2026First: 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:
ls /nope 2> /dev/null
echo "ls exit code: $?"
grep -q Pune orders.csv; echo "found: $?"
grep -q Delhi orders.csv; echo "not found: $?"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 byifor 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.
#!/usr/bin/env bash
set -euo pipefail
echo "start"
cp /nope/file.txt /tmp/
echo "never printed"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:
#!/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"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:
#!/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."./backup.sh
./backup.sh ~/projects/shop-site /srv/backups 3Usage: ./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
returna string.returnonly takes a number 0 to 255; print data and capture it with$( ). - Checking
$?too late. Any command in between, evenecho, replaces it. - Scripts without
set -euo pipefail. Failures go unnoticed and later steps run on bad data.
Key points
- Define functions with
name() { ...; }; uselocalfor their variables and print results for$( )to capture. $1,$2... are arguments,$#is their count and"$@"is all of them safely;shiftmoves them along.- Exit code 0 means success;
$?holds the last one;&&and||act on it; end scripts with a meaningfulexit. - Start scripts with
set -euo pipefailto stop on errors, unset variables and failed pipelines. trap '...' EXITruns 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
#!/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"./check-sites.sh https://shop.example.in https://shop.example.in/missing
echo "exit code: $?"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?
return only sets a numeric status; data is passed by printing.
2. Which expands to all script arguments, keeping each one separate even if it contains spaces?
Quoted "$@" preserves each argument as its own word.
3. What exit code means success?
Zero means success; any non-zero value signals a failure.
4. What does set -u do?
With -u, an unset variable is an error instead of an empty string.
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
