Bash Functions

A Bash function is a named block of commands that you define once and can call as many times as you like, just like calling any other command. Functions let you break a long script into small, reusable, testable pieces instead of copy-pasting the same lines over and over. Once you start writing scripts longer than a few lines, functions are what keep them readable and maintainable.

Overview / How it works

Unlike calling a separate script file (which the kernel loads as a brand-new process with fork() and exec()), calling a Bash function does not create a new process at all. A function runs inside the same shell process that defined it. That single fact explains almost everything about how functions behave:

Because a function shares its parent shell’s process, it can read and modify the same shell variables, change the shell’s current directory with cd, and even exit the whole script — none of which a separate script invoked as a subprocess could do to its caller. This is also why functions are so fast to call: there is no process creation overhead, just a jump to a different point in the same running interpreter.

When Bash reads a script, a function definition does not run its body immediately — it just registers the name and the body in the shell’s function table. The body only executes when you later call the function by name. This means a function normally must be defined before the line that calls it, since Bash reads and executes a script top to bottom.

When a function is called, Bash creates a new set of positional parameters ($1, $2, and so on) scoped to that call, holding whatever arguments were passed. Ordinary variables, however, are global by default in Bash — a function can read and overwrite a variable set outside it unless you explicitly declare the variable local to that function. This is one of the most common sources of bugs in Bash scripts, covered in Common Mistakes below.

A function communicates back to its caller in two very different ways, and mixing them up is another frequent source of confusion:

  • Exit status (via return) — a single integer from 0 to 255, following the same 0-means-success convention as any command. Read it immediately afterward with $?, or test the function directly in an if.
  • Output data (via echo or printf) — the actual text a function wants to hand back, captured by the caller with command substitution, result="$(my_function)".

return cannot hand back a string or a large number — it is strictly an exit status. If you need a function to produce a value like a filename, a count, or a username, print it to stdout and capture it with $().

Syntax

Bash accepts two equivalent forms for defining a function:

<function_name>() {
    <commands>
}

function <function_name> {
    <commands>
}

The first form (name() { ... }) is the POSIX-compatible one and is what you’ll see most often in real scripts; the function keyword form is a Bash-only extension. Either way, the braces must be separated from the commands by whitespace or a newline, and the body must end with a closing } on its own or after a ;.

Inside a function Meaning
$1, $2, … Positional arguments passed to the function call
$# Number of arguments passed to the function
"$@" All arguments, each preserved as a separate word (use this, quoted)
"$*" All arguments joined into a single string
$0 The name of the script (not the function)
local var Declares var scoped to this function call only
return N Sets the function’s exit status to N (0–255)
$? Exit status of the most recently run command or function

Examples

Example 1: A basic function with a parameter

#!/usr/bin/env bash

greet() {
    echo "Hello, $1! Welcome to the server."
}

greet "Priya"
greet "Sam"

Output:

Hello, Priya! Welcome to the server.
Hello, Sam! Welcome to the server.

greet is defined once and called twice with different arguments. Each call gets its own $1, set to whatever string was passed on that particular call.

Example 2: Local variables and an early return

#!/usr/bin/env bash

backup_file() {
    local source="$1"
    local dest_dir="$2"
    local timestamp
    timestamp="$(date +%Y%m%d-%H%M%S)"

    if [[ ! -f "$source" ]]; then
        echo "Error: $source not found" >&2
        return 1
    fi

    cp "$source" "${dest_dir}/$(basename "$source").${timestamp}.bak"
    echo "Backed up $source to $dest_dir"
}

backup_file "/etc/nginx/nginx.conf" "/var/backups"

Output:

Backed up /etc/nginx/nginx.conf to /var/backups

source, dest_dir, and timestamp are all declared local, so they exist only for the duration of this call and cannot collide with variables of the same name elsewhere in the script. If the source file is missing, the function prints an error to stderr and returns exit status 1 without attempting the copy.

Example 3: Returning data vs returning a status

#!/usr/bin/env bash

is_even() {
    local number="$1"
    (( number % 2 == 0 ))
}

double() {
    local number="$1"
    echo $(( number * 2 ))
}

if is_even 42; then
    echo "42 is even"
else
    echo "42 is odd"
fi

result="$(double 21)"
echo "Double of 21 is $result"

Output:

42 is even
Double of 21 is 42

is_even returns only a status: the arithmetic test (( ... )) itself produces exit status 0 or 1, so if is_even 42 works directly. double, by contrast, needs to hand back a number, so it echos it and the caller captures that output with $( ).

How it works step by step

Walking through what happens when Bash reaches backup_file "/etc/nginx/nginx.conf" "/var/backups" in Example 2:

  • Bash looks up backup_file in its function table (already registered earlier when the script was parsed).
  • It creates a new positional-parameter scope for this call: $1 becomes /etc/nginx/nginx.conf, $2 becomes /var/backups.
  • Each local declaration pushes a new variable onto a scope stack, shadowing any global variable of the same name for the rest of this call.
  • The commands inside the function body execute in order, exactly as if they were typed at that point in the script — no new process, no new shell.
  • When the function body finishes (or hits return), Bash pops the local scope, discarding the local variables and restoring the caller’s positional parameters, then sets $? to the function’s exit status.
  • Execution resumes on the line after the function call, with the global shell state (current directory, exported variables, etc.) carrying forward exactly as the function left it.

Common Mistakes

Mistake 1: Forgetting local, so a variable leaks into the caller

#!/usr/bin/env bash

filename="report.txt"

set_extension() {
    filename="${filename%.*}.bak"   # missing 'local' -- overwrites the caller's $filename
}

process() {
    local original="$filename"
    set_extension
    echo "Original was $original, working file is now $filename"
}

process
echo "Script-level filename is now: $filename"

Output:

Original was report.txt, working file is now report.txt.bak
Script-level filename is now: report.txt.bak

set_extension was only meant to compute a temporary value, but because it assigned straight to $filename without local, it permanently overwrote the script-level variable. Any later code expecting the original report.txt is now broken. Fix it by declaring locals and passing values explicitly instead of touching outer variables:

#!/usr/bin/env bash

filename="report.txt"

set_extension() {
    local original="$1"
    echo "${original%.*}.bak"
}

process() {
    local backup_name
    backup_name="$(set_extension "$filename")"
    echo "Original is $filename, backup name is $backup_name"
}

process
echo "Script-level filename is still: $filename"

Mistake 2: Trying to return a string

#!/usr/bin/env bash

get_username() {
    return "alice"   # return only accepts an integer 0-255, not text
}

get_username
echo "Username: $?"

Output:

bash: return: alice: numeric argument required
Username: 2

return is for exit status codes only. To hand back a string, echo it and capture the output with command substitution:

#!/usr/bin/env bash

get_username() {
    echo "alice"
}

username="$(get_username)"
echo "Username: $username"

Mistake 3: Calling a function before it is defined

Because Bash executes a script top to bottom and only registers a function when it reaches the name() { ... } line, calling greet "Dana" before greet is defined fails with command not found, even though the function appears later in the same file. Always define functions (or source a file of functions) before the first line that calls them.

Best Practices

  • Always declare function-scoped variables with local — it prevents accidental collisions with global or caller variables.
  • Use echo/printf plus command substitution to return data; reserve return strictly for a 0–255 exit status.
  • Check $? (or use the function directly in an if) immediately after calling it, before running any other command that would overwrite it.
  • Quote parameter expansions inside functions — "$1", "$@", "$var" — to avoid word-splitting on arguments containing spaces.
  • Give functions verb-based, descriptive names (backup_file, not bf or do_stuff).
  • For larger projects, put related functions in a separate file and load them with source ./lib.sh (or . ./lib.sh) at the top of the main script.
  • Use set -euo pipefail at the top of scripts that call functions doing real work, so an unhandled failure inside a function stops the script instead of continuing silently.
  • If a function needs to run in subshells spawned later (for example inside xargs or a background job), export it first with export -f function_name.

Practice Exercises

  • Write a function file_exists that takes one argument, a path, and returns exit status 0 if the file exists and is readable, 1 otherwise. Use it in an if to print either Found or Missing.
  • Write a small function library file lib.sh containing a log_info function (prints a timestamped message) and a log_error function (prints a timestamped message to stderr). source it from a second script and call both.
  • Write a recursive function factorial that takes a non-negative integer and echoes its factorial (hint: a function can call itself by name inside its own body; make sure there’s a base case that stops the recursion).

Summary

  • A Bash function runs in the same shell process as its caller — no new process is created, unlike running a separate script.
  • Functions must generally be defined before the line that calls them, since Bash executes top to bottom.
  • Variables are global by default inside a function; use local to scope a variable to the current call and avoid leaking into the caller.
  • return sets only an integer exit status (0–255), read via $?; use echo plus $( ) to return actual data.
  • Always quote parameter expansions ("$1", "$@") inside functions to avoid word-splitting.
  • Group related functions into a sourced library file to keep larger scripts organized and reusable.