Bash & the POSIX Shell Module 7 – Functions and script structure
Functions: arguments, local and return status
Define Bash functions with the POSIX and Bash forms, keep variables local with dynamic scoping, use return to set a status and write functions as conditions.
What you will learn
- Define and call functions with arguments using both the POSIX and the Bash syntax
- Use local to limit a variable to a function and explain dynamic scoping
- Set a function's exit status with return and use the function as a condition
Before you start
What you’ll learn · Before you start
What you will learn
- Define and call functions with arguments using both the POSIX and the Bash syntax
- Use local to limit a variable to a function and explain dynamic scoping
- Set a function's exit status with return and use the function as a condition
Before you start
On this page
A script that uploads a report to three servers can repeat the same 12 upload lines three times, 36 lines that must all change together, or define them once as a shell function: A named group of commands defined with name() { …; } or function name { …; }, called like a command. It runs in the current shell, shares its variables unless local is used, and returns a status with return. and call it three times with the server name as its argument. A fix to the upload then goes in one place, and every call picks it up.
A Bash function has two ways to be defined, its own arguments, local variables that stay out of the caller’s way and
a status set with return, which lets it stand directly in an if. Handing data back, rather than a status, is the
subject of the next lesson.
The two definition forms and the body’s compound-command rule
Bash has two forms for defining a function. The POSIX form, name() { commands; }, works in Bash, dash and every
POSIX-compliant shell. The Bash form, function name { commands; }, works in Bash and ksh but not in dash. The
session defines a greet function both ways and shows what dash does with the Bash form:
"""POSIX and Bash function forms, dash's syntax error, the two body rules, and a call before the definition."""
from session import run
run("""
# POSIX form in Bash
greet() { echo "hi"; }
greet
# Bash form in Bash
function greet2 { echo "hi"; }
greet2
# POSIX form in dash
dash -c 'greet() { echo "hi"; }; greet'
# Bash form in dash: syntax error
!dash -c 'function greet { echo "hi"; }'
# Body rules: a space after { and a ; before }
!bash -c 'greet() {echo "hi"; }'
!bash -c 'greet() { echo "hi" }'
# A call before the definition has run
!later
later() { echo "later is defined now"; }
later
""") Output
# POSIX form in Bash
$ greet() { echo "hi"; }
$ greet
hi
# Bash form in Bash
$ function greet2 { echo "hi"; }
$ greet2
hi
# POSIX form in dash
$ dash -c 'greet() { echo "hi"; }; greet'
hi
# Bash form in dash: syntax error
$ dash -c 'function greet { echo "hi"; }'
dash: 1: Syntax error: "}" unexpected
[exit status 2]
# Body rules: a space after { and a ; before }
$ bash -c 'greet() {echo "hi"; }'
bash: -c: line 1: syntax error near unexpected token `{echo'
bash: -c: line 1: `greet() {echo "hi"; }'
[exit status 2]
$ bash -c 'greet() { echo "hi" }'
bash: -c: line 2: syntax error: unexpected end of file from `{' command on line 1
[exit status 2]
# A call before the definition has run
$ later
bash: later: command not found
[exit status 127]
$ later() { echo "later is defined now"; }
$ later
later is defined now
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 forms_session.py
The POSIX form printed “hi” in Bash and in dash. The Bash form worked in Bash too, but dash stopped at its closing
brace with Syntax error: "}" unexpected and status 2; ShellCheck reports the function keyword as SC2113 in a
#!/bin/sh script for this reason. The body is a compound command, a group in braces, and the braces are reserved
words, so two rules hold in both forms: a space or a newline must follow {, and a ;, an & or a newline must come
before }. The two bash -c lines broke one rule each. {echo is one word, not a brace, so Bash reported an
unexpected token; without the ;, Bash read the } as one more argument to echo and reached the end of the input
still looking for the closing brace.
Write the POSIX form unless the script needs Bash anyway
name() { …; } runs in every shell. The function keyword adds nothing in Bash, and it stops a script that runs
under dash, or under a /bin/sh that is not Bash.
A function exists only once its definition has run. The last lines of the session called later before its
definition: Bash answered “command not found” with status 127, as it does for a program that is not installed, and the
same call worked once the definition had run. That is why a script defines its functions first and calls them below.
Checkpoint
Check: The two definition forms
Arguments inside a function are its own positional parameters
Inside a function the arguments are the function’s own positional parameter: An argument of a script or function, read by its position as $1, $2 and from the tenth on ${10}; $# counts them and "$@" passes them all on. More in “Positional and special parameters”:
$1, $2 and so on, with $# counting them and "$@" holding them all. The caller’s arguments are not visible inside
the function; they come back after the function returns. The special parameter $0, though, stays the script’s name,
and FUNCNAME[0] gives the function’s name. The positional-and-special-parameters
lesson covered "$@" and shift; the session shows how a function uses them:
"""Positional parameters inside and outside a function, and FUNCNAME."""
from session import run
run("""
# Outside any function
echo "outside: \\$# = $#, \\$0 = $0"
# Inside a function
show() { echo "inside: \\$# = $#, \\$@ = $@, \\$0 = $0, FUNCNAME = ${FUNCNAME[0]}"; }
show a b c
# After the function returns
echo "after: \\$# = $#"
# Passing all arguments on with "$@"
wrapper() { echo "wrapper received $#, calling show"; show "$@"; }
wrapper a b c
""") Output
# Outside any function
$ echo "outside: \$# = $#, \$0 = $0"
outside: $# = 0, $0 = bash
# Inside a function
$ show() { echo "inside: \$# = $#, \$@ = $@, \$0 = $0, FUNCNAME = ${FUNCNAME[0]}"; }
$ show a b c
inside: $# = 3, $@ = a b c, $0 = bash, FUNCNAME = show
# After the function returns
$ echo "after: \$# = $#"
after: $# = 0
# Passing all arguments on with "$@"
$ wrapper() { echo "wrapper received $#, calling show"; show "$@"; }
$ wrapper a b c
wrapper received 3, calling show
inside: $# = 3, $@ = a b c, $0 = bash, FUNCNAME = show
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 args_session.py
Inside show, $# was 3 and "$@" held a b c; outside, they were 0 and empty. $0 stayed bash in both places
(the session is an interactive shell, whose $0 is bash; in a script both places give the script’s name), while
FUNCNAME[0] gave show inside the function. The wrapper function passed all its arguments on to show with
"$@", so show received the same three words the caller gave wrapper.
Key idea
Inside a function, $1 to $# and "$@" are the function’s own arguments; $0 stays the script’s name and
FUNCNAME[0] gives the function’s name.
Checkpoint
Check: Arguments inside a function
local limits a variable to the function and the functions it calls
Without local, a variable set inside a function is global: it overwrites any variable of the same name and stays
after the function returns. With local, it becomes a local variable: A variable declared with local inside a function, visible only to that function and the functions it calls. It shadows any global of the same name until the function returns. that exists
only in that function and the functions it calls, and any global of the same name
reappears unchanged when the function ends. The session shows both cases and what happens when a called function looks
for a local variable:
"""Global without local, local with local, and dynamic scoping."""
from session import run
run("""
# Without local: global is overwritten
x=global
set_global() { x=inside; }
set_global
echo "after set_global: x=$x"
# With local: global is preserved
x=global
set_local() { local x=inside; echo "inside set_local: x=$x"; }
set_local
echo "after set_local: x=$x"
# Dynamic scoping: inner sees outer's local
x=global
inner() { echo "inner sees: x=$x"; }
outer() { local x=outer; inner; }
outer
echo "after outer: x=$x"
""") Output
# Without local: global is overwritten
$ x=global
$ set_global() { x=inside; }
$ set_global
$ echo "after set_global: x=$x"
after set_global: x=inside
# With local: global is preserved
$ x=global
$ set_local() { local x=inside; echo "inside set_local: x=$x"; }
$ set_local
inside set_local: x=inside
$ echo "after set_local: x=$x"
after set_local: x=global
# Dynamic scoping: inner sees outer's local
$ x=global
$ inner() { echo "inner sees: x=$x"; }
$ outer() { local x=outer; inner; }
$ outer
inner sees: x=outer
$ echo "after outer: x=$x"
after outer: x=global
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 scope_session.py
In the first block, set_global set x without local, so it overwrote the global x and the change persisted.
In the second block, set_local declared local x=inside, so the global stayed global after the call.
The third block shows dynamic scoping: A scoping rule in which a function sees the variables of its caller, not only its own locals. In Bash, a called function can read and change its caller's local variables unless it declares its own local.: outer declared local x=outer, then called
inner, which printed x without declaring its own. Bash looked up the call chain, found outer’s local x and
printed outer. Step through the calls to see which x each function sees:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Global scope starts. The script sets x=global at the top level, before any function call.
- outer is called. The script calls outer. Inside outer, local x=outer shadows the global x.
- outer calls inner. outer calls inner. inner has no local x, so it looks up the call chain.
- inner finds outer's x. Bash finds x=outer from outer, prints "outer" and returns. The global x=global is still shadowed.
- inner returns to outer. inner finishes; control returns to outer. outer's local x is still in scope.
- outer returns. outer finishes; its local x goes away. The global x=global is visible again.
When inner ran, x=outer from outer was in scope because outer called inner. The global x=global was
shadowed. After outer returned, the global reappeared.
local is not POSIX, but dash supports it
POSIX does not require local, but dash has it and Debian Policy allows it in /bin/sh scripts: “local to create
a scoped variable must be supported.”
Checkpoint
Check: local and dynamic scoping
return sets the status and ends the function
A function’s exit status: The number from 0 to 255 every command ends with, kept by the shell in $?. 0 means success; Bash uses 126 for found but not executable and 127 for not found. More in “Your first script: shebang, chmod and running it” is the status of the last command its body ran, unless the
function calls return. return with no argument returns that same status; return N returns N, which must be 0 to
255. If N is outside that range, Bash keeps only the low eight bits, so return 256 gives 0 and return 300 gives 44.
return never prints anything: it only sets a status and ends the function. The session shows all these rules:
"""Return status without return, with return, out of range, with no argument, and a word instead of a number."""
from session import run
run("""
# Status of the last command
last_command() { false; true; }
last_command
echo "last_command status: $?"
# Explicit return status
explicit_status() { return 42; }
!explicit_status
echo "explicit_status status: $?"
# Out of range: 300 mod 256 = 44
out_of_range() { return 300; }
!out_of_range
echo "out_of_range status: $?"
# 256 mod 256 = 0
wrap_zero() { return 256; }
wrap_zero
echo "wrap_zero status: $?"
# return with no argument passes on the last command's status
printf 'apple\\nbanana\\n' > list.txt
found() { grep -q "$1" list.txt || return; echo "found $1"; }
found apple
!found cherry
# return takes a number, never text
not_text() { return hello; }
!not_text
""") Output
# Status of the last command
$ last_command() { false; true; }
$ last_command
$ echo "last_command status: $?"
last_command status: 0
# Explicit return status
$ explicit_status() { return 42; }
$ explicit_status
[exit status 42]
$ echo "explicit_status status: $?"
explicit_status status: 42
# Out of range: 300 mod 256 = 44
$ out_of_range() { return 300; }
$ out_of_range
[exit status 44]
$ echo "out_of_range status: $?"
out_of_range status: 44
# 256 mod 256 = 0
$ wrap_zero() { return 256; }
$ wrap_zero
$ echo "wrap_zero status: $?"
wrap_zero status: 0
# return with no argument passes on the last command's status
$ printf 'apple\nbanana\n' > list.txt
$ found() { grep -q "$1" list.txt || return; echo "found $1"; }
$ found apple
found apple
$ found cherry
[exit status 1]
# return takes a number, never text
$ not_text() { return hello; }
$ not_text
bash: return: hello: numeric argument required
[exit status 2]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 return_session.py
last_command ran false and then true, so its status was that of true, 0. explicit_status returned 42, which
the caller read from $?. out_of_range returned 300 and the caller saw 44, the remainder of 300 divided by 256;
wrap_zero returned 256 and the caller saw 0, a success. found shows return with no argument: for cherry,
grep -q failed with status 1, and || return handed that 1 to the caller without printing anything. not_text
tried to return a word, and Bash refused with “numeric argument required” and status 2.
return carries a status, never text
A function hands text back by printing it with echo or printf, and the caller captures it with $(...). The
next lesson covers that channel and the others.
Checkpoint
Check: return and function status
A function as a condition and recursion with FUNCNEST
A function’s status makes it work as a condition. is_even uses arithmetic evaluation: (( $1 % 2 == 0 )) returns 0
when the remainder is zero, 1 otherwise. The session uses it directly in an if:
"""A function as a condition, a deliberate recursion, and FUNCNEST stopping a runaway one in a child script."""
from session import run, write
write("deep.sh", """FUNCNEST=5
deep() { echo "depth $1"; deep $(( $1 + 1 )); }
deep 1
echo "next line runs, status $?"
""")
run("""
# Function as a condition
is_even() { (( $1 % 2 == 0 )); }
if is_even 4; then echo "4 is even"; else echo "4 is odd"; fi
if is_even 7; then echo "7 is even"; else echo "7 is odd"; fi
# Recursion: factorial
fact() { if (( $1 <= 1 )); then echo 1; else echo $(( $1 * $(fact $(( $1 - 1 ))) )); fi; }
echo "fact 5 = $(fact 5)"
# FUNCNEST limits the depth (a child script, so the session goes on)
cat deep.sh
bash deep.sh
""") Output
# Function as a condition
$ is_even() { (( $1 % 2 == 0 )); }
$ if is_even 4; then echo "4 is even"; else echo "4 is odd"; fi
4 is even
$ if is_even 7; then echo "7 is even"; else echo "7 is odd"; fi
7 is odd
# Recursion: factorial
$ fact() { if (( $1 <= 1 )); then echo 1; else echo $(( $1 * $(fact $(( $1 - 1 ))) )); fi; }
$ echo "fact 5 = $(fact 5)"
fact 5 = 120
# FUNCNEST limits the depth (a child script, so the session goes on)
$ cat deep.sh
FUNCNEST=5
deep() { echo "depth $1"; deep $(( $1 + 1 )); }
deep 1
echo "next line runs, status $?"
$ bash deep.sh
depth 1
depth 2
depth 3
depth 4
depth 5
deep.sh: line 2: deep: maximum function nesting level exceeded (5)
next line runs, status 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 condition_session.py
is_even 4 succeeded, so the if branch ran. is_even 7 failed, so the else branch ran.
Recursion works too: fact called itself with a smaller argument until the argument reached 1, and fact 5 gave 120.
deep has no such stopping rule. With FUNCNEST=5, five nested calls ran and the sixth was refused with “maximum
function nesting level exceeded (5)”: Bash abandoned the command that started the calls, deep 1, and the script went
on with its next line, where $? was 1. Without FUNCNEST, Bash sets no limit of its own, and a runaway recursion
runs until the process’s stack is used up and the shell crashes.
FUNCNEST was added in Bash 4.2
The NEWS file records FUNCNEST as a new feature of bash-4.2. Scripts that must run on older Bash or on dash cannot rely on it.
Checkpoint
Check: Functions as conditions and recursion
Practise: functions, local and return
Write two functions in your own shell and check them against these calls. is_leap_year YEAR prints nothing and
only sets its status: 0 for a leap year of the Gregorian calendar and 1 for any other year, so is_leap_year 2024
and is_leap_year 2000 give 0, while is_leap_year 2023 and is_leap_year 1900 give 1, a century year counting
only when 400 divides it. days_in_month YEAR MONTH prints the number of days and asks is_leap_year about
February: days_in_month 2024 2 prints 29, days_in_month 2023 2 prints 28 and days_in_month 2023 4 prints 30,
while days_in_month 2023 13 prints nothing and returns 1.
The questions below then move the rules onto cases the sessions did not run: a body without its semicolon, a function that
writes into its caller’s local, a status handed on by a bare return, and a nesting limit reached through two
different functions. Settle each answer from the rules above before you open its explanation:
Practice that adapts to you
Practise this lesson
12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): what status does a function return when it has no return, and what range can
return N use?
Without a return, a function returns the exit status of the last command its body ran, so a body that ends with a
failed test returns 1. return N sets N and ends the function; Bash keeps only the low eight bits, so the caller sees
0 to 255: return 256 arrives as 0, a success, and return 300 as 44. return with no argument hands on the status
of the last command, and a word such as return ok is an error with status 2. Text never travels through the status:
a function prints it, and the caller captures it with $(...).
Two harder questions start from symptoms in real scripts: a file loop that handles the wrong files after a helper function gains a loop of its own, and a small wrapper added to a shared library after which every script dies at its first search. Name the likely cause of each before you open it:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A script walks 10 files with for (( i = 0; i < 10; i++ )) and calls check_file on each. After a teammate adds a loop to check_file, one branch handles only the first file and reports success, and another prints the fifth file again and again. What happened, and how do you fix it?
-
Mid to staff
Someone adds grep() { grep --color=never "$@"; } to a library that every team script sources. From then on each script dies at its first grep with a segmentation fault, and one that sets FUNCNEST prints "maximum function nesting level exceeded". What is happening, and how do you change the wrapper?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which way of defining a function works in every POSIX shell?
name() { ...; }. The form function name { ...; } is not POSIX: Bash and ksh accept it, dash stops with a syntax error, and ShellCheck flags it as SC2113 in sh scripts.
-
What do $1, "$@" and $0 mean inside a function?
$1 to $# and "$@" are the function's own arguments. $0 stays the script's name, FUNCNAME[0] gives the function's name, and the caller's arguments come back after the function returns.
-
What does local do, and what happens without it?
local keeps a variable inside the function and the functions it calls. A variable changed without local is the global one, so the function overwrites the caller's value.
-
Can a function read the local variables of the function that called it?
Yes. Bash uses dynamic scoping: a called function sees, and can change, the locals of its caller.
-
What status does a function return, with and without return?
Without return, whatever status the final command of its body left. return N sets N and ends the function, but only 0 to 255 survive: return 256 arrives as 0 and return 300 as 44.
-
When does a function used in if succeed?
When it returns status 0. return prints nothing, so the caller tests the function directly, as in if is_valid "$x"; then.
-
What does FUNCNEST do?
It caps how deeply function calls may nest, from Bash 4.2. With FUNCNEST=5 the sixth nested call was refused: Bash gave up the whole command that began the recursion, $? was 1, and the next line of the script still ran. Without a cap, runaway recursion runs until the stack is used up and the shell crashes.
Key takeaways
- The POSIX form
name() { …; }works in Bash, dash and every POSIX shell; the formfunction name { …; }is not POSIX: Bash and ksh accept it, dash stops with a syntax error, and ShellCheck flags it as SC2113 in sh scripts. - Inside a function,
$1to$#and"$@"are the function’s own arguments;$0stays the script’s name andFUNCNAME[0]gives the function’s name; the caller’s arguments come back after the function returns. localkeeps a variable inside the function; Bash uses dynamic scoping, so a called function sees the locals of its caller, and a change withoutlocaloverwrites the global.return Nsets status 0–255 and ends the function; without it the function returns the status of its last command;returnprints nothing, and a function used as a condition succeeds when its status is 0.FUNCNEST(Bash 4.2) caps how deeply function calls may nest and abandons the command that goes deeper, with status 1; without it a runaway recursion runs until the stack is used up and the shell crashes.
Terms in this lesson
- dynamic scoping
- A scoping rule in which a function sees the variables of its caller, not only its own locals. In Bash, a called function can read and change its caller's local variables unless it declares its own local.
- local variable
- A variable declared with local inside a function, visible only to that function and the functions it calls. It shadows any global of the same name until the function returns.
- shell function
- A named group of commands defined with name() { …; } or function name { …; }, called like a command. It runs in the current shell, shares its variables unless local is used, and returns a status with return.
References
- Bash Reference Manual: Shell Functions (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Bourne Shell Builtins (local, return) (Chet Ramey (GNU Bash maintainer))
- Bash Reference Manual: Bash Variables (FUNCNAME, FUNCNEST) (Chet Ramey (GNU Bash maintainer))
- POSIX.1-2024: Shell Command Language (2.9.5 Function Definition Command) (The Open Group and IEEE)
- POSIX.1-2024: return (special built-in) (The Open Group and IEEE)
- ShellCheck: SC2113 (function keyword is non-standard) (ShellCheck)
- Debian Policy Manual: 10.4 Scripts (Debian)
Report a problem with this lesson
Kept only in this browser. Your Learn progress