Shell Scripting Functions: Write Reusable Bash Code
As your scripts grow beyond a few dozen lines, you'll find yourself repeating the same logic — validating inputs, logging messages, checking file permissions. Functions solve this by letting you write a block of code once and call it whenever you need it. They make your scripts modular, readable, and far easier to maintain.
In this guide, we'll cover how to define functions, pass arguments, handle return values, manage variable scope, and build reusable code libraries in Bash.
Defining Functions
Bash offers two equivalent syntaxes for defining functions. Both work identically — choose the one that fits your style or team conventions.
Syntax 1: With the function Keyword
1#!/bin/bash 2 3function greet() { 4 echo "Hello, World!" 5}
Syntax 2: Without the function Keyword
1#!/bin/bash 2 3greet() { 4 echo "Hello, World!" 5}
Best Practice: The
functionkeyword is more explicit and readable, especially for beginners. Many style guides recommend it for clarity.
Calling Functions
Once defined, call a function by using its name followed by parentheses (optional in Bash):
1#!/bin/bash 2 3function greet() { 4 echo "Hello, $1!" 5} 6 7greet "Alice" 8greet "Bob"
Output:
Hello, Alice!
Hello, Bob!
Functions must be defined before they are called. Bash processes scripts top-to-bottom, so place function definitions near the top of your file or source them from an external library.
Functions with Return Values
Unlike most programming languages, Bash functions don't return data using the return keyword. Instead, they use two mechanisms:
echo— to return string/number data (captured with command substitution)return— to return an exit status code (0-255, for success/failure signaling)
Returning Data with echo
1#!/bin/bash 2 3function add_numbers() { 4 local a=$1 5 local b=$2 6 echo $((a + b)) # This output is captured by the caller 7} 8 9result=$(add_numbers 10 20) 10echo "The sum is: $result"
Output:
The sum is: 30
How it works:
$(add_numbers 10 20)runs the function in a subshell and captures everything the function prints to stdout. This is the standard pattern for returning values in Bash.
Returning Exit Status with return
Use return to indicate success or failure, just like any other command:
1#!/bin/bash 2 3function check_file() { 4 if [ -f "$1" ]; then 5 return 0 # Success 6 else 7 return 1 # Failure 8 fi 9} 10 11if check_file "/etc/passwd"; then 12 echo "File exists." 13else 14 echo "File not found." 15fi
Exit Status Reference:
| Status | Meaning |
|---|---|
0 | Success |
1-255 | Error or specific condition |
$? | Special variable holding the last exit status |
Practical Example: A Validation Library
1#!/bin/bash 2 3function is_valid_email() { 4 local email=$1 5 if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then 6 return 0 7 else 8 return 1 9 fi 10} 11 12function is_valid_port() { 13 local port=$1 14 if [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ]; then 15 return 0 16 else 17 return 1 18 fi 19} 20 21# Usage 22if is_valid_email "admin@example.com"; then 23 echo "Valid email" 24fi 25 26if ! is_valid_port "70000"; then 27 echo "Invalid port number" 28fi
Function Variables and Scope
By default, all variables in Bash are global — accessible anywhere in the script, including inside functions. This can lead to bugs when functions accidentally overwrite variables used elsewhere.
The Problem with Global Variables
1#!/bin/bash 2 3global_var="I am global" 4 5my_function() { 6 local_var="I am local... or am I?" 7 global_var="I was changed!" 8} 9 10my_function 11echo "$global_var" # Output: I was changed! 12echo "$local_var" # Output: I am local... or am I?
Notice that local_var is also accessible outside the function. In Bash, unless you explicitly declare a variable as local, it becomes global.
Using local for Function-Scoped Variables
1#!/bin/bash 2 3global_var="I am global" 4 5my_function() { 6 local local_var="I am truly local" 7 local global_var="This is a different variable" 8 9 echo "Inside function: $global_var" 10 echo "Inside function: $local_var" 11} 12 13my_function 14echo "Outside function: $global_var" # Original value preserved 15echo "Outside function: $local_var" # Empty — doesn't exist here
Output:
Inside function: This is a different variable
Inside function: I am truly local
Outside function: I am global
Outside function:
Golden Rule: Always declare function variables with
localunless you intentionally want to modify a global state. This prevents subtle bugs and makes your functions self-contained and reusable.
Local Variable Best Practices
1#!/bin/bash 2 3function process_file() { 4 local filepath=$1 5 local filename 6 local filesize 7 local checksum 8 9 filename=$(basename "$filepath") 10 filesize=$(stat -c%s "$filepath" 2>/dev/null || stat -f%z "$filepath") 11 checksum=$(md5sum "$filepath" | awk '{print $1}') 12 13 echo "$filename | $filesize bytes | $checksum" 14}
Functions with Multiple Arguments
Functions access arguments through positional parameters — the same $1, $2, $@ syntax used by scripts themselves.
Accessing Arguments
1#!/bin/bash 2 3function create_user() { 4 local username=$1 5 local shell=${2:-/bin/bash} # Default to /bin/bash if not provided 6 local home=${3:-/home/$username} 7 8 echo "Creating user: $username" 9 echo " Shell: $shell" 10 echo " Home: $home" 11} 12 13create_user "alice" 14create_user "bob" "/bin/zsh" "/data/users/bob"
Output:
Creating user: alice
Shell: /bin/bash
Home: /home/alice
Creating user: bob
Shell: /bin/zsh
Home: /data/users/bob
Argument Validation Inside Functions
1#!/bin/bash 2 3function deploy_app() { 4 local app_name=$1 5 local version=$2 6 local environment=${3:-production} 7 8 if [ -z "$app_name" ] || [ -z "$version" ]; then 9 echo "Usage: deploy_app <app_name> <version> [environment]" >&2 10 return 1 11 fi 12 13 echo "Deploying $app_name v$version to $environment..." 14 # deployment logic here 15} 16 17deploy_app "api-service" "2.1.0" 18deploy_app "web-app" "1.5.3" "staging" 19deploy_app # This will show the error message
Handling All Arguments with $@
When you don't know how many arguments you'll receive, use $@ to iterate over all of them:
1#!/bin/bash 2 3function backup_files() { 4 local dest_dir=$1 5 shift # Remove first argument, leaving only files 6 7 echo "Backing up to: $dest_dir" 8 for file in "$@"; do 9 if [ -f "$file" ]; then 10 cp "$file" "$dest_dir/" 11 echo " ✓ $file" 12 else 13 echo " ✗ $file (not found)" >&2 14 fi 15 done 16} 17 18backup_files "/backups/today" file1.txt file2.log file3.conf
Using shift for Argument Processing
The shift command moves all positional parameters down by one ($2 becomes $1, $3 becomes $2, etc.):
1#!/bin/bash 2 3function process_options() { 4 local verbose=false 5 local output_file="" 6 7 while [ $# -gt 0 ]; do 8 case "$1" in 9 -v|--verbose) 10 verbose=true 11 shift 12 ;; 13 -o|--output) 14 output_file="$2" 15 shift 2 16 ;; 17 *) 18 echo "Unknown option: $1" >&2 19 return 1 20 ;; 21 esac 22 done 23 24 echo "Verbose: $verbose" 25 echo "Output: $output_file" 26} 27 28process_options -v --output report.txt
Advanced Function Patterns
Function Libraries and Sourcing
As your project grows, organize functions into reusable library files:
lib/utils.sh:
1#!/bin/bash 2 3function log_info() { 4 echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') - $*" 5} 6 7function log_error() { 8 echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') - $*" >&2 9} 10 11function die() { 12 log_error "$@" 13 exit 1 14} 15 16function require_root() { 17 if [ "$(id -u)" -ne 0 ]; then 18 die "This script must be run as root" 19 fi 20}
main.sh:
1#!/bin/bash 2 3source "$(dirname "$0")/lib/utils.sh" 4 5log_info "Starting system update..." 6require_root 7 8# Your main logic here 9log_info "Update completed."
Recursive Functions
Bash functions can call themselves, though recursion is rarely needed in shell scripting:
1#!/bin/bash 2 3function countdown() { 4 local n=$1 5 6 if [ "$n" -le 0 ]; then 7 echo "Launch!" 8 return 9 fi 10 11 echo "$n..." 12 sleep 1 13 countdown $((n - 1)) 14} 15 16countdown 5
Functions as Callbacks
Pass function names as strings and call them dynamically:
1#!/bin/bash 2 3function on_success() { 4 echo "Operation completed successfully!" 5} 6 7function on_failure() { 8 echo "Operation failed!" >&2 9} 10 11function run_with_callback() { 12 local cmd=$1 13 local success_cb=$2 14 local failure_cb=$3 15 16 if eval "$cmd"; then 17 $success_cb 18 else 19 $failure_cb 20 fi 21} 22 23run_with_callback "ls /valid/path" on_success on_failure 24run_with_callback "ls /invalid/path" on_success on_failure
Real-World Example: A Complete Backup Utility
Here's a production-style script that demonstrates functions, local variables, return values, and argument handling:
1#!/bin/bash 2 3set -euo pipefail 4 5# ─── Configuration ─── 6readonly BACKUP_BASE="/backups" 7readonly RETENTION_DAYS=7 8 9# ─── Logging Functions ─── 10function log() { 11 local level=$1 12 shift 13 echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" 14} 15 16function info() { log "INFO" "$@"; } 17function warn() { log "WARN" "$@" >&2; } 18function error() { log "ERROR" "$@" >&2; } 19 20# ─── Core Functions ─── 21 22function ensure_dir() { 23 local dir=$1 24 if [ ! -d "$dir" ]; then 25 mkdir -p "$dir" || { 26 error "Failed to create directory: $dir" 27 return 1 28 } 29 info "Created directory: $dir" 30 fi 31} 32 33function calculate_size() { 34 local path=$1 35 if [ -f "$path" ]; then 36 stat -c%s "$path" 2>/dev/null || stat -f%z "$path" 37 elif [ -d "$path" ]; then 38 du -sb "$path" | cut -f1 39 else 40 echo 0 41 fi 42} 43 44function create_backup() { 45 local source=$1 46 local label=${2:-backup} 47 local timestamp 48 local backup_name 49 local backup_path 50 51 timestamp=$(date +%Y%m%d_%H%M%S) 52 backup_name="${label}_${timestamp}.tar.gz" 53 backup_path="$BACKUP_BASE/$backup_name" 54 55 if [ ! -e "$source" ]; then 56 error "Source does not exist: $source" 57 return 1 58 fi 59 60 info "Creating backup: $backup_name" 61 info "Source: $source" 62 63 local source_size 64 source_size=$(calculate_size "$source") 65 info "Source size: $(numfmt --to=iec $source_size)" 66 67 tar -czf "$backup_path" -C "$(dirname "$source")" "$(basename "$source")" 68 69 local backup_size 70 backup_size=$(calculate_size "$backup_path") 71 info "Backup complete: $backup_name ($(numfmt --to=iec $backup_size))" 72 73 echo "$backup_path" 74} 75 76function cleanup_old_backups() { 77 local label=$1 78 local count 79 80 count=$(find "$BACKUP_BASE" -name "${label}_*.tar.gz" -type f | wc -l) 81 82 if [ "$count" -gt "$RETENTION_DAYS" ]; then 83 info "Cleaning up old backups for: $label" 84 find "$BACKUP_BASE" -name "${label}_*.tar.gz" -type f -printf '%T@ %p\n' | \ 85 sort -n | \ 86 head -n -$RETENTION_DAYS | \ 87 cut -d' ' -f2- | \ 88 while read -r old_backup; do 89 rm "$old_backup" 90 info "Removed: $(basename "$old_backup")" 91 done 92 fi 93} 94 95# ─── Main ─── 96function main() { 97 if [ $# -lt 1 ]; then 98 echo "Usage: $0 <source_path> [backup_label]" >&2 99 exit 1 100 fi 101 102 local source=$1 103 local label=${2:-$(basename "$source")} 104 105 ensure_dir "$BACKUP_BASE" 106 107 local backup_file 108 backup_file=$(create_backup "$source" "$label") 109 110 if [ $? -eq 0 ]; then 111 cleanup_old_backups "$label" 112 info "All operations completed successfully." 113 else 114 error "Backup failed." 115 exit 1 116 fi 117} 118 119main "$@"
Best Practices for Writing Functions
- Always use
localfor variables inside functions to avoid polluting the global namespace - Quote your parameters —
local file="$1"notlocal file=$1 - Provide sensible defaults —
local port=${1:-8080} - Validate inputs early and return meaningful error codes
- Write single-purpose functions — one function should do one thing well
- Document with comments above each function explaining parameters and return behavior
- Use
returnfor status,echofor data — don't mix the two purposes - Print errors to stderr —
echo "error" >&2so they don't get captured by$() - Name functions descriptively —
is_valid_emailis clearer thancheck1 - Source common libraries instead of copying functions between scripts
Quick Reference
1# Define function 2function name() { ... } 3name() { ... } 4 5# Call function 6name arg1 arg2 7 8# Access arguments 9$1 $2 $3 ... $@ $# 10 11# Return data 12echo "result" # Caller: result=$(myfunc) 13 14# Return status 15return 0 # Success 16return 1 # Failure 17 18# Check status 19if myfunc; then ...; fi 20 21# Local variables 22local var="value" 23 24# Defaults 25local port=${1:-8080} 26 27# Shift arguments 28shift # Remove $1 29shift 2 # Remove $1 and $2
Functions are the building blocks of professional shell scripting. They transform sprawling, repetitive scripts into clean, modular, testable code. Once you start organizing your logic into well-named functions with clear inputs and outputs, you'll find your scripts become easier to write, debug, and maintain.
Next up: Arrays and string manipulation — the data structures that let you handle complex datasets in Bash.