Skip to content
DevOps devops shell 6 min read

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.

MethodWhat it gives backRange / typeUse it for
return NAn exit code (a status number)An integer 0-255 onlySuccess or failure (0 = success)
echo "..."Text output you captureAny string or numberActual 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 echo extra status messages inside a function whose output you plan to capture. Anything you echo becomes part of the returned data. Send progress messages to standard error instead with echo "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 local to prevent global leaks.
  • Always quote argument variables: "$1", "$@", never bare $1.
  • Use return only for success/failure status (0 is success), and echo for 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_file or check_disk so calls read like sentences.
  • Add set -euo pipefail at the top so the script stops on the first error instead of charging ahead.
Last updated June 15, 2026
Was this helpful?