Functions
A function is a named block of code that you can run again and again just by calling its name. Instead of copying the same ten lines into three places in your script, you write them once inside a function and call that function wherever you need it. This keeps scripts shorter, easier to read, and far easier to fix, because a bug only lives in one place. On a real Ubuntu server, where you might write a deploy script or a backup script that repeats the same checks over and over, functions are the difference between a tidy tool and an unmaintainable mess.
Defining a function
A function in Bash (the default shell on Ubuntu, short for “Bourne Again SHell”) is just a name attached to a group of commands. There are two ways to write one. Both behave the same, so pick one style and stick to it.
#!/usr/bin/env bash
# Style 1: the keyword "function" (clear for beginners)
function greet {
echo "Hello from the server"
}
# Style 2: parentheses (the POSIX standard, more portable)
say_bye() {
echo "Goodbye"
}
# You call a function by writing its name, like any command
greet
say_bye
Output:
Hello from the server
Goodbye
Two important rules. First, a function must be defined before you call it, because Bash reads the file top to bottom. Second, calling a function uses no parentheses and no commas. You write greet, not greet().
Define all your functions near the top of the script, then put the “main” logic that calls them at the bottom. This makes scripts read like a table of contents followed by the action.
Passing arguments
Functions receive arguments the same way the whole script does: through numbered variables. Inside a function, $1 is the first argument you passed, $2 is the second, and so on. The special variable $@ means “all the arguments,” and $# is the count of arguments.
#!/usr/bin/env bash
backup_file() {
local source="$1"
local label="$2"
echo "Backing up $source as $label"
echo "Received $# argument(s): $@"
}
backup_file /etc/nginx/nginx.conf nginx-config
Output:
Backing up /etc/nginx/nginx.conf as nginx-config
Received 2 argument(s): /etc/nginx/nginx.conf nginx-config
Always wrap your argument variables in double quotes ("$1", not $1). A file path like /var/log/my app.log contains a space, and without quotes Bash would split it into two separate arguments and break your script.
Use local to avoid global leaks
By default, every variable in Bash is global. That means a variable you set inside a function keeps its value after the function ends, and can silently overwrite a variable used elsewhere in your script. This causes confusing, hard-to-find bugs. The fix is the local keyword, which makes a variable exist only inside the function that created it.
#!/usr/bin/env bash
count=10 # a global variable used by the main script
bad_function() {
count=99 # no "local" -> this clobbers the global!
}
good_function() {
local count=99 # safe -> only exists inside this function
}
bad_function
echo "After bad_function, count is $count"
count=10
good_function
echo "After good_function, count is $count"
Output:
After bad_function, count is 99
After good_function, count is 10
When to use it: always declare every variable inside a function with local unless you have a deliberate reason to change something outside the function. It costs nothing and saves you from a whole category of bugs.
Returning a result: return vs echo
This trips up almost everyone at first. A function gives back information in two completely different ways, and they are used for two different purposes.
| Method | What it gives back | Range / type | Use it for |
|---|---|---|---|
return N | An exit code (a status number) | An integer 0-255 only | Success or failure (0 = success) |
echo "..." | Text output you capture | Any string or number | Actual data, like a value or filename |
return does not hand back a string. It sets the exit code, which you read with $? right after the call, or test directly with if. Use it to signal “did this work or not.”
#!/usr/bin/env bash
service_is_running() {
local name="$1"
if systemctl is-active --quiet "$name"; then
return 0 # 0 means success / true
else
return 1 # any non-zero means failure / false
fi
}
if service_is_running nginx; then
echo "nginx is up"
else
echo "nginx is down"
fi
Output:
nginx is up
To return actual data such as a number or a string, you echo it and capture the output with command substitution $( ).
#!/usr/bin/env bash
disk_usage_percent() {
# Read the used % of the root filesystem, strip the % sign
df --output=pcent / | tail -n 1 | tr -d ' %'
}
usage=$(disk_usage_percent)
echo "Root disk is ${usage}% full"
Output:
Root disk is 41% full
Never
echoextra status messages inside a function whose output you plan to capture. Anything youechobecomes part of the returned data. Send progress messages to standard error instead withecho "working..." >&2, which prints to the screen but stays out of the captured value.
Refactoring repeated code into a function
Here is the real payoff. Imagine a setup script that logs three steps the same way each time. The repetition is easy to get wrong and tedious to change.
# Before: the same logging pattern copied three times
echo "[$(date '+%H:%M:%S')] Updating package list"
sudo apt-get update -y
echo "[$(date '+%H:%M:%S')] Installing nginx"
sudo apt-get install -y nginx
echo "[$(date '+%H:%M:%S')] Enabling firewall"
sudo ufw allow 'Nginx Full'
Pull the repeated part into one function. Now there is a single place to change the log format, and the script reads like plain instructions.
#!/usr/bin/env bash
set -euo pipefail
log() {
local message="$1"
echo "[$(date '+%H:%M:%S')] $message"
}
run_step() {
local message="$1"
shift # drop the message, leaving only the command
log "$message"
"$@" # run the remaining arguments as a command
}
run_step "Updating package list" sudo apt-get update -y
run_step "Installing nginx" sudo apt-get install -y nginx
run_step "Enabling firewall" sudo ufw allow 'Nginx Full'
Output:
[14:02:11] Updating package list
[14:02:14] Installing nginx
[14:02:31] Enabling firewall
The shift command throws away $1 so the rest of the arguments line up as the actual command to run, and "$@" executes them. This pattern is common in production deploy scripts.
Best practices
- Define functions before you call them, grouped at the top of the script.
- Declare every internal variable with
localto prevent global leaks. - Always quote argument variables:
"$1","$@", never bare$1. - Use
returnonly for success/failure status (0is success), andechofor data you capture with$( ). - Send progress messages to standard error (
>&2) so they never pollute a captured return value. - Give functions short, verb-based names like
backup_fileorcheck_diskso calls read like sentences. - Add
set -euo pipefailat the top so the script stops on the first error instead of charging ahead.