We ended the previous lesson with an uncomfortable diagnosis: loops had removed the repetition between runs of the script, but inside the file we were still writing the same validation block three times and the same formatting printf four times. In Bash there are no classes, no modules, no objects: the function is the only unit of reuse that exists. Anything you want to write once and use many times —validate, format, log, compute— has to be a function. In this lesson you will learn to define them, to pass them data, to get results back (which is not what it looks like) and to document them; and at the end you will refactor daily-report.sh so that it stops being a wall of code and becomes a set of named pieces.
Contents
- The two syntaxes and which to prefer
- Where they are defined: definitions on top,
mainat the bottom - Parameters: functions are mini-scripts
local, and the bug waiting for you if you forget itreturnreturns an exit code, not data- How to really return data
- Functions that fail, and recursion
- Functions versus scripts and aliases
- Documenting a function
- Refactoring
daily-report.sh
- The two syntaxes and which to prefer
Bash accepts two ways of declaring a function, plus a third that mixes them:
greet() { echo "Hello from Veloz Envíos"; } # POSIX form: the recommended one
function greet { echo "Hello"; } # keyword: Bash/ksh
function greet() { echo "Hello"; } # mix: valid but redundant| Aspect | name() { } |
function name { } |
|---|---|---|
| Portability | POSIX: works in sh, dash, zsh |
Bash, ksh, zsh only |
| Readability | The parentheses mark it as callable | The keyword stands out more |
| Recommendation | Always use it | Avoid it unless it is team style |
We will use name() { ... } throughout the course. It is the one ShellCheck expects (08-05) and the one that keeps working if one day the script has to run under sh (08-07).
Naming rules: letters, digits and underscores; by convention, lowercase with _. Do not put parentheses when calling it: you invoke it like any other command, greet, not greet().
- Where they are defined: definitions on top,
main at the bottom
main at the bottomBash reads the script top to bottom. A function only exists after the interpreter has gone past its definition:
From this comes the structural pattern you will use in all your scripts from now on:
#!/usr/bin/env bash
readonly CSV_PATH="/srv/veloz/data/shipments.csv" # 1) header and constants
usage() { ... } # 2) ALL the definitions
validate_env() { ... }
city_summary() { ... }
main() { validate_env; city_summary "$1"; } # 3) the main flow
main "$@" # 4) single call, last lineConcrete advantages of this skeleton: the reader finds the program flow in main and not by digging through 300 lines; nothing runs until the last line, so the definition order between functions stops mattering (when main runs, they are all loaded); and main "$@" forwards the script's arguments to the function, with the quotes around $@ that 03-05 declared mandatory.
- Parameters: functions are mini-scripts
Here is the surprise for anyone coming from other languages: parameters are not declared in the signature. A function receives arguments exactly like a script: $1, $2, $#, "$@", shift.
count_status() {
(( $# >= 2 )) || { echo "count_status: missing arguments" >&2; return 2; }
local city="$1" status="$2"
grep -c ",${city},[^,]*,${status}," "$CSV_PATH"
}
count_status Valencia issue # called with no commas and no parenthesesTwo surprising subtleties: $0 does not change inside a function (it is still the script name; for the function name there is ${FUNCNAME[0]}), and $1 are the arguments of the function, not those of the script, so if you need them inside you have to pass them along with my_function "$@".
local, and the bug waiting for you if you forget it
local, and the bug waiting for you if you forget itBy default, all Bash variables are global, including the ones you assign inside a function. This produces one of the hardest bugs in the language to track down:
count() { total=0; for x in 1 2 3; do total=$(( total + x )); done; echo "$total"; }
total=999
count # prints 6
echo "$total" # prints 6, not 999: the function clobbered your variableThe function has destroyed a variable of the main program without warning. The fix is to declare local on every internal working variable:
count() { local total=0 x; for x in 1 2 3; do total=$(( total+x )); done; echo "$total"; }
total=999; count; echo "$total" # prints 6 and then 999 ✓Details about local: it is only valid inside a function; it takes several at once (local a b c=0); its scope is dynamic, so a function called from another sees the caller's local variables (do not rely on it). And beware of local var=$(command): the $? left behind is local's, always 0, not the command's; if you need to check it, split it into local var; var=$(command) || return 1.
Golden rule: if a variable is not declared local, it is because you deliberately want it to outlive the function.
return returns an exit code, not data
return returns an exit code, not datareturn N ends the function and sets $? to N. It is the same mechanism as the exit codes from 03-01, with the same limitations: an integer between 0 and 255, where 0 means success.
csv_usable() {
[[ -f "$CSV_PATH" ]] || return 3 # does not exist
[[ -r "$CSV_PATH" ]] || return 4 # no read permission
[[ -s "$CSV_PATH" ]] || return 5 # empty
}
if csv_usable; then echo "CSV ready"; else echo "CSV not usable ($?)" >&2; fiSince the function returns a code, it can be used directly in an if, in an &&, in a while… just like any command. That is why check functions are given question-like names (csv_usable, is_valid_city) and return 0/1.
What you cannot do is return 3.5, return "Valencia" or return 1000 (it is truncated to 1000 % 256 = 232). If you omit return, the function returns the code of the last command executed.
- How to really return data
There are three techniques, in order of preference:
a) Write to stdout and capture with $( ). This is the idiomatic form:
average_amount() { awk -F, -v c="$1" '$3==c {s+=$6;n++} END{printf "%.2f",s/n}' "$CSV_PATH"; }
m=$(average_amount Valencia); echo "Average in Valencia: $m €"Its cost: $( ) creates a subshell, and the function cannot write anything else to stdout —progress messages included— without contaminating the result. That is why informational messages always go to stderr, as you will see in log_info below.
b) Assign to an agreed global variable (result=...). Fast and with no subshell, but it couples the function to a specific name.
c) Nameref with local -n (Bash 4.3+). The caller decides the name of the output variable and the function writes into it indirectly:
average_amount() {
local city="$1"
local -n _output="$2" # _output is an ALIAS of the variable named in $2
_output=$(awk -F, -v c="$city" '$3==c {s+=$6;n++} END{printf "%.2f",s/n}' "$CSV_PATH")
}
average_amount Valencia avg_val
echo "$avg_val" # 26.34local -n is the scoped version of declare -n. It is the technique you will use to "return" arrays (04-03), where $( ) would lose the separation between elements. Trap: the local name cannot match the one the caller passes or Bash raises a circular reference error; that is why it is prefixed with _.
- Functions that fail, and recursion
Chaining functions with &&, || and $? works just like with external commands: validate_env || exit $? propagates the function's code to the operating system, and csv_usable && process_csv chains. Careful: exit inside a function kills the whole script, not just the function. That is correct in abort functions like die(), but in a reusable function prefer return and let the caller decide.
Bash supports recursion —factorial() { local n="$1"; (( n <= 1 )) && { echo 1; return; }; echo $(( n * $(factorial $(( n - 1 )) ) )); }— but each level creates a subshell because of the $( ), so it is slow; the limit is set by FUNCNEST. In operations scripts, a loop is almost always clearer and faster.
- Functions versus scripts and aliases
| Criterion | Alias | Function | Separate script |
|---|---|---|---|
| Accepts arguments | No (it just appends them) | Yes ($1, "$@") |
Yes |
Logic with if/loops |
No | Yes | Yes |
| Where it lives | ~/.bashrc |
In the script or in lib/ |
Its own file in bin/ |
| Callable from another program | No | No (unless export -f) |
Yes |
| Execution cost | None | None (same process) | A new process |
| Can modify the current shell | Yes | Yes | No |
Practical translation: aliases are personal keyboard shortcuts (alias ll='ls -l'), functions are reuse inside a program, and scripts are the unit invoked from outside —cron, systemd, another script—. daily-report.sh is a script; validate_env is one of its functions.
- Documenting a function
A function with no header forces you to read its body to know how it is used. Adopt this format from today:
# city_summary — Prints the shipment summary of a city on a date.
# Usage: city_summary <city> <date>
# Arguments: $1 city (Valencia|Sevilla|Bilbao|Madrid) $2 date (yyyy-MM-dd)
# Output: One formatted line on stdout
# Returns: 0 if there is data; 6 if the city has no shipments that day
city_summary() { ... }Four fixed sections —usage, arguments, output, return code— and one line of description. It is cheap to write and removes half the questions about the script.
- Refactoring
daily-report.sh
daily-report.shWe apply everything above. This is the script's skeleton after the refactor (we omit the option parsing, which you already have from 03-05):
#!/usr/bin/env bash
# daily-report.sh — Daily shipment report for Veloz Envíos.
readonly CSV_PATH="${VELOZ_CSV:-/srv/veloz/data/shipments.csv}"
readonly LOG_PATH="${VELOZ_LOG:-/var/log/veloz/app.log}"
readonly CITIES=(Valencia Sevilla Bilbao Madrid)
# log_info/log_error — Log a timestamped message to STDERR.
log_info() { printf '%s [INFO] %s\n' "$(date '+%F %T')" "$*" >&2; }
log_error() { printf '%s [ERROR] %s\n' "$(date '+%F %T')" "$*" >&2; }
# validate_env — Checks that CSV and log exist and are readable.
# Returns: 0 ok | 3 missing file | 4 no read permission
validate_env() {
local f
for f in "$CSV_PATH" "$LOG_PATH"; do
[[ -f "$f" ]] || { log_error "Does not exist: $f"; return 3; }
[[ -r "$f" ]] || { log_error "Not readable: $f"; return 4; }
done
log_info "Environment validated"
}
# count_errors — Number of [ERROR] lines in the log on a date. Output: integer.
count_errors() { grep -c "^${1:?} .*\[ERROR\]" "$LOG_PATH" || true; }
# city_summary — Formatted line with a city's totals.
# Usage: city_summary <city> <date> Returns: 0 | 6 if there are no shipments
city_summary() {
local city="${1:?}" report_date="${2:?}" total issues
total=$(grep -c "^[^,]*,${report_date},${city}," "$CSV_PATH")
(( total > 0 )) || { log_info "No shipments in $city"; return 6; }
issues=$(grep "^[^,]*,${report_date},${city}," "$CSV_PATH" | grep -c ',issue,')
printf '%-10s %5d shipments %5d issues\n' "$city" "$total" "$issues"
}
main() {
local report_date="${1:-$(date +%F)}" city
validate_env || exit $?
log_info "Report for $report_date"
printf '%-10s %12s %16s\n' CITY SHIPMENTS ISSUES
for city in "${CITIES[@]}"; do city_summary "$city" "$report_date"; done
log_info "Errors in the log: $(count_errors "$report_date")"
}
main "$@"What we have gained: a single place that validates (if tomorrow we have to check the configuration file, you touch validate_env and nothing else); messages separated from data, because log_info writes to stderr so that daily-report.sh > report.txt saves a clean table while the warnings stay on screen —the stream separation from 02-04 applied with judgment—; a four-line loop instead of four identical blocks, so adding Zaragoza means adding one word to CITIES; and a main that fits on one screen and reads like the statement of the problem.
The || true in count_errors deserves an explanation: grep -c returns code 1 when it counts 0 matches, which in a script with set -e (05-03) would abort the program. || true forces a 0 code without altering the output.
These functions are generic and you will want them in other toolkit scripts too. In 05-06 you will move them to ~/veloz-ops/lib/common.sh and load them with source, turning the toolkit into a real library.
Common Mistakes and Tips
- Calling the function with parentheses:
city_summary("Valencia")is not a syntax error, it is something else and will fail in strange ways. You call it ascity_summary Valencia. - Forgetting
local. The most expensive bug in this lesson. Declare everything internal local, always. - Mixing messages and result on stdout. If the function is captured with
$( ), any progressechoends up inside the result. Messages → stderr. - Believing that
returnreturns data. It returns a 0-255 code.return $(wc -l < file)with 300 lines would return 44. - Defining a function after using it. Definitions on top,
main "$@"at the end. - Tip: name functions with a verb (
validate_,count_,generate_) and check functions as predicates (is_valid,exists_). The name should make reading the body unnecessary.
Exercises
Exercise 1. Write is_valid_city() that takes a city and returns 0 if it is one of the four operational ones and 1 otherwise, printing nothing. Use it in an if.
Exercise 2. Write shipments_by() that takes a courier and writes its number of shipments to stdout, and show that it is captured with $( ). Add the full documentation header.
Exercise 3. Fix this function, which has two defects:
total_amount() {
sum=0
while IFS=, read -r _ _ _ _ _ amount; do sum=$(( sum + ${amount%.*} )); done < "$1"
return $sum
}Solutions
Solution 1.
is_valid_city() {
local city="${1:?city missing}" c
for c in Valencia Sevilla Bilbao Madrid; do [[ "$city" == "$c" ]] && return 0; done
return 1
}
if is_valid_city "$1"; then echo "OK"; else echo "Unknown city" >&2; fiIn 04-05 you will see that a case does this in three lines, and in 04-03 that an associative array does it in one.
Solution 2.
# shipments_by — Counts a courier's shipments.
# Usage: shipments_by <courier> Arguments: $1 (alopez|mgarcia|jruiz)
# Output: integer on stdout Returns: 0 always
shipments_by() { local r="${1:?courier missing}"; grep -c ",${r}," "$CSV_PATH" || true; }
n=$(shipments_by alopez); echo "alopez made $n shipments"Solution 3. The two defects are that sum is global and that return cannot return a total (it is truncated modulo 256):
total_amount() {
local file="${1:?}" sum=0 amount
while IFS=, read -r _ _ _ _ _ amount; do sum=$(( sum + ${amount%.*} )); done < "$file"
echo "$sum" # the result goes out on stdout, not via return
}
total=$(total_amount "$CSV_PATH")By the way: ${amount%.*} trims the decimal part —an expansion you will master in 04-04— because Bash does not add decimals, something you will solve properly in 04-06.
Conclusion
Functions turn a script into a program. They are defined with name() { }, placed all on top with a main "$@" as the last line, receive arguments like a script through $1 and "$@", protect their internal state with local, signal success or failure with return (a code, never data) and deliver results by writing to stdout so the caller can capture them with $( ) —or through a nameref when that is not enough—. With validate_env, count_errors, city_summary and the two logging functions, daily-report.sh now has a skeleton.
What it lacks is memory. Our main already uses "${CITIES[@]}" without having explained what that syntax is, and the loop re-reads the whole CSV once per city because it has nowhere to accumulate a counter for each one. That is arrays (04-03): indexed lists and, better still, key→value maps that let you count issues by city and by courier in a single pass, replacing the old sort | uniq -c we have been dragging along since Module 2.
Bash Programming Course
Module 1: Introduction to Bash
- What Is Bash?
- Setting Up Your Environment
- Basic Command Line Navigation
- Understanding the Shell
- Finding Help: man, help and --help
Module 2: Basic Bash Commands
- File and Directory Operations
- Text Processing Commands
- File Permissions and Ownership
- Redirection and Piping
- Wildcards and Path Expansion
- History and Keyboard Shortcuts
Module 3: Scripting Fundamentals
- Creating and Running a Script
- Variables and Constants
- Basic Operators
- Conditional Statements
- Arguments and User Input
- Quoting, Expansion and Substitution
Module 4: Intermediate Scripting
- Loops in Bash
- Functions in Bash
- Arrays and Associative Arrays
- String Manipulation
- The case Statement and Interactive Menus
- Arithmetic and Numeric Calculations
Module 5: Advanced Scripting Techniques
- Advanced File Operations
- Process Management
- Error Handling and Debugging
- Regular Expressions
- Advanced I/O: Descriptors and Here-Documents
- Modular Scripts and Reusable Libraries
Module 6: Working with External Tools
Module 7: Automation and Scheduling
- Cron Jobs
- Automating Tasks
- Backup and Restore Scripts
- Monitoring and Logging
- Services and Timers with systemd
- Remote Automation with SSH
Module 8: Best Practices and Optimization
- Writing Readable Code
- Optimizing Bash Scripts
- Security Considerations
- Version Control with Git
- Static Analysis with ShellCheck and shfmt
- Automated Testing with Bats
- Portability: POSIX sh versus Bashisms
