#

Bash

Subscribe
Bash October 29, 2026·9 min read

Bash Functions: Arguments, Return Values, and Scope

Use Bash functions for reusable commands: pass quoted arguments, print result data to stdout, and use the exit status to signal success or failure. To change an outer variable, call the function directly, because the $(...) form runs in a subshell and its variable changes never reach the caller. A combined local declaration and assignment hides a failed command, so test one normal call and one failure case before reusing a function.

A Bash function can be as simple as:

greet() {
  printf 'Hello, %s\n' "$1"
}

greet 'two words'

Output:

Hello, two words

The sample outputs in this guide come from GNU Bash 5.2.37(1) on x86_64 GNU/Linux.

How to define and call a Bash function

bash function declaration

A function stores commands under a name. The body runs when you call that name, and the call has to come after the definition has run. On one line, greet() { printf 'Hello, %s\n' "$1"; } needs the semicolon before the closing brace, while the multiline form above uses line breaks instead. Bash also accepts function greet { ...; }, a Bash-specific spelling that POSIX shells don't all support.

How to pass arguments to Bash functions

bash function arguments

A function receives its arguments as positional parameters: $1 is the first, $2 the second, and $# reports how many were supplied.

A function gets its own positional parameter list. Arguments your script received reach the function only if you pass them along.

This function prints its argument count and each supplied argument:

show_args() {
  printf 'count=%s\n' "$#"
  if (( $# > 0 )); then
    printf '[%s]\n' "$@"
  fi
}

show_args 'two words' '*.txt'

Output:

count=2
[two words]
[*.txt]

The space inside two words survives, *.txt stays literal, and with zero arguments the guard prints only count=0.

Quote "$@". Inside double quotes it keeps each argument intact. Unquoted, $@ goes through word splitting and pathname expansion, which changes what the function receives.

To pass your script's arguments along, call:

show_args "$@"

ShellCheck covers this in SC2086, which explains how unquoted expansions lead to word splitting and glob expansion.

For arguments beyond the ninth, use braces. ${10} refers to argument ten, while $10 is parsed as $1 followed by a literal zero.

quoted argument forwarding

When positional arguments become awkward

Two or three stable inputs read fine:

copy_report "$source" "$destination"

Trouble starts when a function collects optional settings and callers have to pass empty placeholders to reach a later parameter.

On r/bash, a programmer building an AWS SSM helper put the difficulty plainly: “Now obviously it kinda sucks that I am passing an empty string to indicate that we dont have an AWS vault user and I have to also memorize the position of the arguments”

Named options such as --user or --region make a call easier to read. They also need an option parser, validation, and documentation, which is a lot of work for a small helper.

Bash return vs. function output

bash return status

A function talks to its caller in two ways: it prints data, and it finishes with an exit status. Using both channels interchangeably makes error handling unreliable.

The return builtin sets that status. By convention, zero means success and a nonzero value means failure or another condition the function defines. Without an explicit return, the function inherits the status of its last executed command.

One learner working through a Bash course put it this way: “As far as I know there is no way to return non-integer values from a function and return only sets $?.”

get_data() {
  printf 'data\n'
  return 7
}

result=$(get_data)
status=$?

printf 'value=%s status=%s\n' "$result" "$status"

Output:

value=data status=7

Bash stores the last command's exit status in $?, and the next command overwrites it, so the example saves it right after the command substitution. A function that executes return 34 has status 34 and no result text to capture.

function exit values

Bash keeps only the low eight bits of a numeric return status:

Function statementExit status
return 00
return 77
return 2560
return 30044

Large values get reduced (256 becomes 0, 300 becomes 44), so arbitrary numeric results belong in an output channel.

How to send results and diagnostics back to the caller

stdout stderr channels

$(function) captures standard output, while standard error stays separate unless the caller redirects it.

This function emits one data value and one diagnostic message:

report() {
  printf '42\n'
  printf 'notice\n' >&2
}

result=$(report)

printf 'captured=%s\n' "$result"

The variable holds 42, and notice appears on its own.

This separation matters when you use the result as a filename, a number, or other text. A progress message inserted into stdout becomes part of the captured data. The $(...) form also removes trailing newline characters from captured stdout, so if those newlines matter, the captured text differs from what the function printed.

ChannelMechanismCaller receivesUse
Standard outputprintf '%s\n' "$value"Captured text or piped dataFunction result
Exit statusreturn n$? or conditional resultSuccess and failure
Standard errorprintf 'error\n' >&2Diagnostic streamErrors and notices
Caller-visible variableDirect assignment during a function callUpdated surrounding stateDeliberate state changes

Assign to an outer variable directly only when updating shared state is the function's purpose.

function output streams

You can discard stderr with 2>/dev/null, but then a real failure message disappears and troubleshooting gets harder.

Local variables, dynamic scope, and command substitution

bash variable scope

A function you call directly runs in the calling shell's environment, so it can change variables the caller sees.

The $(function) form runs it in a subshell, so its assignments never reach the parent shell. Bash 5.3 also documents a different substitution form that runs in the current shell.

A script author ran into the same behavior: “If you run this code you'll notice VALUE remains 0, even though VALUE was not assigned using the local keyword.”

VALUE=0

change() {
  VALUE=1
  printf 'value=%s\n' "$VALUE"
}

result=$(change)

printf 'captured=%s parent=%s\n' "$result" "$VALUE"

change >/dev/null

printf 'direct_parent=%s\n' "$VALUE"

Output:

captured=value=1 parent=0
direct_parent=1

Called inside command substitution, change leaves the caller's VALUE at 0. The direct call updates it to 1, and its stdout goes to /dev/null just to keep the output short.

The GNU Bash function documentation describes this behavior and Bash's variable-scope rules.

subshell variable behavior

What local changes

Inside a function, local name='Jane' creates a variable in that function's dynamic scope. That limits accidental changes to surrounding variables, but it doesn't make the variable private: any function called from inside can read it, so a nested function sees name as Jane. Once the defining function returns, the variable is gone. This is dynamic scoping, which differs from the lexical scoping of some other languages.

Why local value=$(command) can hide a failure

local assignment failure

Consider this expression inside a function:

local value=$(false)

false exits with a nonzero status, but the status you see afterward is the status of the local declaration, which succeeded.

Compare the two forms:

masked() {
  local value=$(false)
  printf 'masked_status=%s\n' "$?"
}

fixed() {
  local value
  value=$(false)
  printf 'captured_status=%s\n' "$?"
}

masked
fixed

Output:

masked_status=0
captured_status=1

The first function reports 0 because the declaration succeeds and hides the failure inside it. The second reports 1: with the declaration on its own line, the assignment's status is that of the failed command substitution.

ShellCheck flags this as SC2155.

ShellCheck documents the same status-masking problem for combined assignments with export and readonly.

Whenever the result matters, declare first and assign on the next line so you get a status you can check.

Why set -e does not catch every function failure

bash errexit behavior

set -e, also called errexit, terminates a shell after certain failed commands, but its behavior changes with context, including whether the command is evaluated as part of an if condition.

The exception shows up when a function contains a failing command followed by a successful one:

check() {
  false
  printf 'still running\n'
}

(
  set -e

  if check; then
    printf 'function_success\n'
  fi
)

Output:

still running
function_success

false failed, yet the function carried on to printf. Since the last command succeeded, the if treated the function as successful.

The parentheses run the example in a subshell, so it leaves your main shell's settings alone. The GNU Bash documentation for set describes the contexts where errexit is ignored.

conditional error handling

When a failure needs a specific response, use an explicit conditional:

operation() { return 1; }

if result=$(operation); then
  printf 'result=%s\n' "$result"
else
  printf 'operation failed\n' >&2
fi

Output on stderr:

operation failed

How to check and troubleshoot a Bash function

bash function checks

A function is ready to reuse when it does what its callers expect.

SymptomCauseWhat to inspect
Function name reports command not foundDefinition has not executedDefinition and invocation order
One argument becomes severalWord splitting or glob expansionQuoting around forwarded arguments
return 34 produces no captured textStatus is being interpreted as outputStdout and exit status
Parent variable remains unchangedInvocation through command substitutionDirect-call versus captured-call context
Failed command appears successfulMasked status or conditional errexit behaviorAssignment structure and invocation context

Check syntax first

For a script saved as script.sh, Bash has a syntax-only check:

bash -n script.sh

This parses the script without executing its commands. A clean result means Bash found no syntax errors. It says nothing about whether the script does what you intend.

When ShellCheck is installed, run:

shellcheck script.sh

ShellCheck runs static analysis and flags suspicious patterns. These rules apply to the behaviors in this guide:

  • SC2086: Unquoted expansions.
  • SC2120: A function references positional arguments without receiving the expected parameters.
  • SC2155: Declaration combined with command substitution.

Neither bash -n nor ShellCheck runs your function.

bash smoke testing

Run a small behavioral test

A smoke test should cover what the function promises, including one case that is supposed to fail.

  • Normal input: Supply a representative argument and compare the result with what you expect.
  • Argument boundaries: Include a value with spaces and a literal wildcard, then check the arguments the function received.
  • Missing input: If the function allows an omitted or empty argument, test that case.
  • Failure handling: Trigger a deliberately failing operation and check the status the caller gets.
  • State changes: If changing an outer variable is part of the function's job, check that variable after the call.

Check stdout, stderr, the exit status, and any intended state changes separately. A script can pass its syntax check and still receive the wrong number of arguments or report a failed operation as successful.

NoScript Team