Functions in Bash: Writing Reusable Code (Part 21 / 34)

You are sitting down to write a 300-line deployment script and realize the same 15 lines of disk-space checking code appear in four different places. If you make a change in one location, you now have to go hunting through your entire codebase to see where else those lines of code exist. And if you miss one, you end up chasing strange bugs later when something suddenly stops working. I did exactly that about two years ago while working on scripts for our staging environment. Writing functions solved that problem forever.

If you have worked on more than a few dozen shell scripts, you have probably come across this issue already. The copy-paste method of scripting works perfectly well until it does not. This guide is written for anyone learning Bash functions for the first time, as well as experienced sysadmins who want to better organize scripts using arguments, return values, local variables, scope, and reusable scripting patterns that actually make sense in production environments.

Why This Matters Right Now:

Most bash scripts start small. A backup job here, a user-creation tool there. Then someone adds requirements, another person edits it, and six months later you have a 500-line script with duplicated logic, no error handling, and variables bleeding into each other in ways nobody expected.

  • Functions let you write a block of logic once and call it from anywhere in the script
  • Local variables inside functions stop accidental overwrites of values defined elsewhere
  • Named functions make your script readable enough that you can understand it six months later

If your scripts are getting harder to maintain, functions are probably the first thing worth adding.

Before we get into writing functions, it helps to understand the building blocks they rely on. If you want a refresher on the core syntax used in shell scripts, the what is bash scripting guide on LinuxTeck covers the foundation well.

#01

Before You Write a Single Function

A bash function is a named block of commands. You define it once, then call it by name whenever you need it. That is the whole idea. No imports, no compilation, just a name and some curly braces.

There are two ways to define a function in bash. Both work identically. The first uses the function keyword, the second skips it. Most working scripts you will find in the wild use the shorter form without the keyword, but you will see both.

bash
LinuxTeck.com
#!/bin/bash

# Method 1: using the function keyword
function greet_user {
echo "Hello, welcome to the server"
}

# Method 2: without the keyword (more common)
greet_user() {
echo "Hello, welcome to the server"
}

# Call the function
greet_user

OUTPUT
Hello, welcome to the server

One rule that catches people early: you must define a function before you call it. Bash reads scripts top to bottom. Call a function before it is defined and bash will throw a "command not found" error. Keep your function definitions at the top of the script or in a dedicated section before your main logic runs.

Note:

A function definition does not run any code. It just registers the function name and stores the commands. Nothing inside the curly braces executes until you call the function by name.

#02

Passing Arguments Into Your Functions

A function that always does exactly the same thing with hardcoded values is only slightly more useful than just writing the commands inline. Arguments are what make functions genuinely flexible.

In bash, you pass arguments to a function the same way you pass them to a script. Space-separated, after the function name. Inside the function, they show up as $1, $2, $3, and so on. $@ gives you all of them as a list. $# tells you how many were passed.

bash
LinuxTeck.com
#!/bin/bash

create_user() {
local username=$1
local role=$2

if [ -z "$username" ]; then
echo "Error: no username provided"
return 1
fi

echo "Creating user: $username with role: $role"
useradd -m -c "$role" "$username"
echo "User $username created successfully"
}

create_user "john" "developer"
create_user "sarah" "admin"

OUTPUT
Creating user: john with role: developer
User john created successfully
Creating user: sarah with role: admin
User sarah created successfully

Common Mistake:

The useradd command writes to system files like /etc/passwd and /etc/shadow. Running this script as a regular user will immediately fail with a "Permission denied" error. You must run it with root privileges: sudo bash create_user.sh. In a real deployment, the script itself would typically be owned by root with restricted execute permissions so only authorized users or automation tools can invoke it.

A few things worth noticing in that example. The arguments are immediately assigned to local variables with descriptive names right at the top of the function. That makes the code readable. $1 and $2 mean nothing to someone reading the script later. $username and $role do. Also notice the check for an empty username at the top. Validating inputs inside functions is a habit worth building early.

#03

The Variable Scope Problem Nobody Warns You About

This one bites a lot of people. In most programming languages, variables declared inside a function are automatically local. They only exist inside that function. Bash does not work that way.

In bash, every variable you declare inside a function is global by default. It leaks out into the rest of the script after the function runs. If you happen to use the same variable name somewhere else in the script, you just silently overwrote it. No error. No warning. Just wrong behavior that is annoying to debug.

The fix is the local keyword. Use it every time you declare a variable inside a function and you have no intention of sharing that value outside it.

bash
LinuxTeck.com
#!/bin/bash

STATUS="idle"

run_check() {
local STATUS="running" # local: stays inside this function
RESULT="done" # global: leaks out after function returns
echo "Inside function: STATUS=$STATUS"
}

run_check
echo "Outside function: STATUS=$STATUS"
echo "Outside function: RESULT=$RESULT"

OUTPUT
Inside function: STATUS=running
Outside function: STATUS=idle
Outside function: RESULT=done

The global STATUS stayed "idle" because the local one inside the function shadowed it without touching it. But RESULT was set without local, so it is now accessible everywhere. That is intentional here, but in a 400-line script, an unintentional leak like that will take you a while to find. Default to local for everything inside a function unless you specifically need it to be global.

#04

Return Values: What $? Actually Does and What to Do Instead

The return statement in bash is not like return in Python or JavaScript. It does not pass a value back to the caller. It sets an exit code, a number between 0 and 255. Zero means success. Anything else means something went wrong. That is it.

If you want to actually get data out of a function, you have two real options. Either print it with echo and capture it with command substitution, or set a global variable intentionally. Both patterns are used in production scripts.

bash
LinuxTeck.com
#!/bin/bash

# Pattern 1: echo + command substitution
get_disk_usage() {
local path=$1
df -Ph "$path" | awk 'NR==2 {print $5}'
}

USAGE=$(get_disk_usage "/")
echo "Root partition is $USAGE full"

# Pattern 2: return code for pass/fail checks
check_space() {
local threshold=$1
local used
used=$(df -P / | awk 'NR==2 {print $5}' | tr -d '%')
if [ "$used" -gt "$threshold" ]; then
return 1
fi
return 0
}

if check_space 80; then
echo "Disk usage is within safe limits"
else
echo "WARNING: disk usage exceeds 80%"
fi

OUTPUT
Root partition is 34% full
Disk usage is within safe limits

The exit code pattern works well with if statements because bash evaluates any command in an if condition and checks whether it returned 0. So if check_space 80 reads cleanly and does exactly what it looks like. This is the pattern you see in well-written production scripts constantly. You can read more about handling exit codes properly in the bash exit codes and error handling guide.

#05

The Mistake That Wrecks Scripts at Scale

Here is the pattern that gets people. They write a function, it works, and then they write 10 more functions, all with short internal variable names like temp, count, result, or status. None of them use local. Six months later, calling one function silently overwrites a variable that another function was relying on, and the script produces wrong output with no error message.

Common Mistake:

Skipping local on internal variables and reusing common names like count, result, or tmp across multiple functions. Since all variables in bash are global unless declared local, calling any function that uses those names will silently overwrite whatever value was there before.

Fix: Always declare function-internal variables with local. Example: local count=0 instead of just count=0. Every variable inside a function that is not deliberately shared should be local. This one habit prevents a whole class of hard-to-trace bugs.

Related to this: do not use exit inside a function when you mean return. exit terminates the entire script. return exits just the function and passes control back to the caller. If you call exit 1 inside a helper function to signal an error, you will kill the whole script even if the caller was prepared to handle that failure gracefully.

bash
LinuxTeck.com
#!/bin/bash

# WRONG: exit inside a function kills the whole script
check_file_wrong() {
if [ ! -f "$1" ]; then
echo "File not found: $1"
exit 1 # This ends the script, not just the function
fi
}

# CORRECT: use return to exit the function only
check_file() {
local filepath=$1
if [ ! -f "$filepath" ]; then
echo "File not found: $filepath"
return 1
fi
return 0
}

if ! check_file "/etc/myapp.conf"; then
echo "Config missing, using defaults instead"
fi
echo "Script continues normally here"

OUTPUT
File not found: /etc/myapp.conf
Config missing, using defaults instead
Script continues normally here
#06

A Real-World Example: Server Health Check Script

Everything covered so far comes together when you build something real. This is the kind of script that lives on a server and gets run via cron or a monitoring agent. It checks disk space and whether key services are running. Each check is its own function. The main section just calls them in order and decides what to do based on exit codes.

This pattern, separate functions for each check, a main function that orchestrates them, is how scripts stay manageable as they grow. You can add a new check by writing one new function and adding one line to main. You can test each check in isolation. You can read the script top-to-bottom and understand exactly what it does. The bash scripting automation guide has more patterns like this for ongoing maintenance tasks.

bash
LinuxTeck.com
#!/bin/bash
# server_health.sh - run via cron every 15 minutes

LOG_FILE="/var/log/health_check.log"

log_message() {
local level=$1
local message=$2
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $message" | tee -a "$LOG_FILE"
}

check_disk() {
local threshold=$1
local usage
usage=$(df -P / | awk 'NR==2 {print $5}' | tr -d '%')
if [ "$usage" -gt "$threshold" ]; then
log_message "WARN" "Disk at ${usage}% (limit: ${threshold}%)"
return 1
fi
log_message "OK" "Disk at ${usage}%"
return 0
}

check_service() {
local service=$1
if ! systemctl is-active --quiet "$service"; then
log_message "CRIT" "Service $service is not running"
return 1
fi
log_message "OK" "Service $service is running"
return 0
}

main() {
local errors=0
check_disk 80 || (( ++errors ))
check_service nginx || (( ++errors ))
check_service sshd || (( ++errors ))
if [ "$errors" -gt 0 ]; then
log_message "ALERT" "$errors check(s) failed"
return 1
fi
log_message "INFO" "All checks passed"
}

main

OUTPUT
[2026-05-29 08:14:02] [OK] Disk at 42%
[2026-05-29 08:14:02] [OK] Service nginx is running
[2026-05-29 08:14:02] [OK] Service sshd is running
[2026-05-29 08:14:02] [INFO] All checks passed

Notice the main() function at the bottom and the single call to main at the very end. This structure is worth adopting early. It keeps the script readable, it means you can source the script in another script and use only specific functions without triggering the whole thing, and it separates the "what functions exist" section from the "what runs" section cleanly.

Tip:

When debugging a function-based script, run it with bash -x yourscript.sh. Bash will print every command as it executes, including which function it is running and what the variables expand to. For larger scripts, add set -x just before the function call you want to trace, and set +x after it to limit the noise.

FAQ

Frequently Asked Questions

Can I call a function before I define it in the script?

No. Bash reads scripts from top to bottom, so a function must be defined before it is called. The standard approach is to put all your function definitions near the top of the script, then have your main logic below them. If you are calling functions from a main() function, that is fine since main itself is not called until all the function definitions above it have been processed.

Why does my function variable affect code outside the function?

Because bash variables are global by default, even inside functions. Any variable you set inside a function without the local keyword is visible and writable from anywhere in the script after that function runs. Add local in front of every variable declaration inside your functions and the leak stops.

What is the difference between return and exit inside a function?

return exits the current function and hands control back to whatever called it, passing a numeric exit code between 0 and 255. exit terminates the entire script process. Inside a function, you almost always want return. Use exit only when you genuinely want the script to stop completely, and even then it is usually cleaner to let main() handle that decision rather than a nested helper function.

How do I get a string value back from a function, not just a number?

Use echo inside the function to print the value, then capture it with command substitution when you call the function. Like this: result=$(my_function "arg"). Whatever the function echoes becomes the value of result. Just be aware that every echo inside the function, including debug messages, will be captured. Keep functions that return values clean and quiet.

Can I pass an array to a bash function?

Not directly, because bash passes arguments as individual strings. The practical approach for small arrays is to pass the elements individually using "${array[@]}" and read them inside the function with $@. For larger or more complex scenarios, declare the array name as a nameref variable inside the function using local -n arr=$1, which gives the function a reference to the original array. This requires bash 4.3 or newer, which covers most modern Linux systems.

Should I put all my functions in a separate file and source it?

Yes, once you start reusing the same functions across multiple scripts that is the right move. Create a library file like lib_common.sh, put your shared functions there, and source it at the top of any script that needs them with source /path/to/lib_common.sh or the shorthand . /path/to/lib_common.sh. Use absolute paths in production scripts so they work regardless of where the script is called from.

END

Summary

Now that you have this working, the next thing worth adding to your scripts is proper error handling around those function calls. A function that uses return 1 correctly is only useful if the caller actually checks that exit code and responds to it. Combine bash functions with set -e or explicit || checks and you have the building blocks of scripts that fail cleanly instead of silently. The official GNU Bash manual section on shell functions is worth bookmarking for the edge cases, particularly around function environments and inheritance.

For scheduling these kinds of scripts to run automatically, the cron command guide covers everything you need to get health checks and maintenance scripts running on a timer.

Related Articles

LinuxTeck - A Complete Linux Learning Blog
Learn step-by-step how to automate Linux tasks with real-world scripts and practical examples.

About Sharon J

Sharon J is a Linux System Administrator with strong expertise in server and system management. She turns real-world experience into practical Linux guides on Linux Teck.

View all posts by Sharon J →

Leave a Reply

Your email address will not be published.

L