---
title: 'Functions and Scope'
source: 'https://academia.sh/en/courses/shell-programming/functions-and-scope'
course: 'Shell Programming'
language: en
updated: '2026-08-17T18:10:01+00:00'
license: 'CC BY-SA 4.0'
---

# Functions and Scope

Function definition, argument passing, the distinction between returning an exit code and returning a value, local variables, and dynamic scope.

As the script grows, the same work repeats in more than one place:
reporting errors, printing a section header, counting and sorting a field.
In the Programming Fundamentals course, the counterpart of this was the
function, and a function was a construct that took named parameters and
returned a value.

A function in the shell is a different thing. **It is a named command**: it
is called with positional parameters, it returns an exit code, and it
writes its result — if it produces a value — to its standard output. That
is, a shell function is a program's counterpart inside the shell.

## Definition and Invocation

```sh
section() { printf '\n== %s ==\n' "$*"; }
section "Most requested paths"
```

```

== Most requested paths ==
```

The body sits between braces. Because braces are recognized as words,
spaces around them are mandatory, and a `;` or newline is required after
the last command. Bash also recognizes the `function name { ... }` form;
the form above is the POSIX one.

A function must be defined at the point it is called. The shell reads the
file from top to bottom; a call before the definition gives a "command not
found" error:

```sh
call_first
echo "code=$?"
```

```
./f.sh: line 14: call_first: command not found
code=127
```

This is why functions are gathered at the start of the script, above the
code that does the work.

## Arguments

When a function is called, positional parameters are replaced with the
function's own: `$1`, `$2`, `$#`, `"$@"` point at the arguments given to
the function. There is one exception — `$0` does not change, it keeps
pointing at the script's name.

```sh
who() { echo "0=$0 1=$1 count=$#"; }
who a b
```

```
0=./f.sh 1=a count=2
```

The rule from the second lesson applies to argument passing: `"$@"` is
used, `$*` is written only to produce message text.

Shell functions have no signature; a function called with a missing
argument does not error, `$1` stays empty. Required arguments must be
tested at the start of the body:

```sh
class_name() {
  [ "$#" -eq 1 ] || { echo "class_name: expects one argument" >&2; return 2; }
  ...
}
```

## Returning an Exit Code

`return` ends the function and sets the exit code. The code is subject to
the same contract as process exit codes: 0 success, nonzero failure, range
0–255.

```sh
valid_code() {
  case "$1" in
    [1-5][0-9][0-9]) return 0 ;;
    *)               return 1 ;;
  esac
}
valid_code 404; echo "404 -> $?"
valid_code abc; echo "abc -> $?"
```

```
404 -> 0
abc -> 1
```

Out-of-range values are reduced modulo 256; a function that writes
`return 300` returns `44`.

`return` should not be confused with `exit`. `return` only exits the
function; `exit` terminates the entire script.

```sh
#!/usr/bin/env bash
f() { echo "inside f"; return 1; }
g() { echo "inside g"; exit 4; }
f; echo "continuing after f, code=$?"
g; echo "THIS LINE IS NOT WRITTEN"
```

```
inside f
continuing after f, code=1
inside g
```

```sh
echo $?
```

```
4
```

Rule: library-like functions use `return` and leave the decision to the
caller; only helpers that function as "fatal error" handlers call `exit`.

## Returning a Value

`return` is not a tool for returning a value; a code in the range 0–255
cannot carry a string or a large number. In the shell, the way to return a
value is to write to standard output, with the caller retrieving it
through command substitution.

```sh
class_name() {
  case "$1" in
    2) echo "successful"    ;;
    3) echo "redirect"      ;;
    4) echo "client error"  ;;
    5) echo "server error"  ;;
    *) echo "unknown"; return 1 ;;
  esac
}
name=$(class_name 4); echo "4 -> $name"
```

```
4 -> client error
```

This contract has a direct consequence: **a function that returns a value
cannot write anything else to standard output.** If it needs to print a
progress message or a warning, it writes to standard error. This is the
clearest example of why the stream distinction from the topic's first
lesson is needed in practice.

The second consequence is cost: command substitution creates a subshell.
In scripts that make many calls inside a loop, this leads to a measurable
slowdown. Writing the value to a global variable is the alternative;
because it lowers readability, it is used only when needed.

## Scope: Global by Default

Variables in the shell are **global** by default. An assignment made
inside a function is also visible outside.

```sh
counter=0
increment() { counter=$((counter + 1)); }
increment; increment; echo "counter=$counter"
```

```
counter=2
```

This is the reverse of the default in the Programming Fundamentals course;
there, an assignment in a function body created a local name. The shell's
behavior is a consequence of the function being designed as a "command" —
commands see shell variables too.

The `local` declaration binds the name to the function:

```sh
x="outside"
demo() { local x="inside"; echo "inside the function: $x"; }
demo; echo "outside the function: $x"
```

```
inside the function: inside
outside the function: outside
```

`local` is not defined in the POSIX shell language; it exists in almost
all common shells, but using it in a script declaring `#!/bin/sh` removes
the portability guarantee.

Rule: **every temporary variable used inside a function is declared
`local`.** An undeclared name silently overwrites the same-named variable
in the calling context; this is the hardest class of bug to find in long
scripts.

## Dynamic Scope

A name declared `local` is visible not only to the function it is defined
in, but to **every function that function calls**. The shell's scope model
is not lexical, it is **dynamic**.

```sh
inner() { echo "inner sees: $secret"; }
outer() { local secret="from the outer function"; inner; }
outer
echo "main shell sees: [${secret-undefined}]"
```

```
inner sees: from the outer function
main shell sees: [undefined]
```

The `inner` function saw the name `secret` even though it never defined
it, because that name was active along the call chain. This visibility
would not occur under the lexical scoping introduced in the Programming
Fundamentals course; there, which definition a name bound to was
determined by looking at where the code was written.

Practical consequence: your functions' behavior can become dependent on
the local variables of a caller they never see. The way to prevent this is
for functions to take every value they need as an argument.

## `local` and the Exit Code

`local` is a command, and it produces its own exit code. Doing the
declaration and the assignment on the same line makes the right-hand
command's code invisible:

```sh
fail() { return 3; }
try1() { local d; d=$(fail); echo "separate assignment  -> $?"; }
try2() { local d=$(fail);    echo "combined with local  -> $?"; }
try1; try2
```

```
separate assignment  -> 3
combined with local  -> 0
```

The `0` seen in the second function is the `local` command's own code.
Under strict mode, this leads to a failed command not stopping the script
— the same trap will be taken up again in the Error Handling lesson. Rule:
**declaration and value assignment are written on separate lines.**

## Functions with a Subshell Body

If the body is written with parentheses instead of braces, the function
runs in a subshell. Side effects such as changing directory, setting
`IFS`, or turning on a shell option do not leak to the caller:

```sh
temp() ( cd /tmp && pwd )
```

Its cost is the cost of creating a process and the loss of global variable
assignments. If isolation is genuinely needed, it is the right tool.

## Applying It to the Script

`report.sh` takes the repeated work into functions:

```sh
SCRIPT_NAME="report.sh"

error() { printf '%s: error: %s\n'   "$SCRIPT_NAME" "$*" >&2; }
warn()  { printf '%s: warning: %s\n' "$SCRIPT_NAME" "$*" >&2; }
die()   { error "$1"; exit "${2:-1}"; }
section() { printf '\n== %s ==\n' "$*"; }

class_name() {
  case "$1" in
    2) echo "successful"    ;;
    3) echo "redirect"      ;;
    4) echo "client error"  ;;
    5) echo "server error"  ;;
    *) echo "unknown"; return 1 ;;
  esac
}

field_count() {
  local field="$1" file="$2"
  cut -d' ' -f"$field" "$file" | sort | uniq -c | sort -rn
}
```

The checks come down to a single line each, and error codes keep their
meaning:

```sh
log_file=access.log

[[ "$count" =~ ^[1-9][0-9]*$ ]] || die "-n expects a positive integer: $count" 2
[ -f "$log_file" ] || die "not a regular file or does not exist: $log_file" 3
[ -r "$log_file" ] || die "no read permission: $log_file" 3
[ -s "$log_file" ] || warn "log is empty: $log_file"
```

```sh
./report.sh -n 3
```

```

== Log: access.log ==

== Most requested 3 paths ==
   6 /api/data
   4 /missing.html
   4 /index.html

== Status code classes ==
  2xx successful        18
  3xx redirect           3
  4xx client error       6
  5xx server error       3
```

```sh
./report.sh missing.log; echo "code=$?"
```

```
report.sh: error: not a regular file or does not exist: missing.log
code=3
```

Because the `die` function calls `exit`, the script stops there; `error`
and `warn` only write and leave the decision to the caller. Separating the
three functions this way will provide a single touch point when strict
mode is added in later lessons.

## Summary

- A shell function is a named command: it is called with positional
  parameters, returns an exit code, and writes its result to standard
  output. `$0` does not change inside a function.
- `return` sets the exit code and only exits the function; `exit`
  terminates the script.
- The way to return a value is to write to standard output; because of
  this, a function that returns a value must write its warnings to
  standard error.
- Variables are global by default; the `local` declaration binds the name
  to the function, and should be written for every temporary variable.
- The shell's scope is dynamic: a `local` name is also visible to called
  functions.
- The form `local d=$(command)` hides the command's exit code; declaration
  and assignment are kept separate.

## Next Step

Every count in the report reads the log from start to end once more. Five
counts mean five scans. Bringing this down to a single scan requires
keeping intermediate results in memory. The next lesson takes up arrays
and associative arrays; from which bash version the latter is available,
and what to do when it is not, will be shown there too.
