Control Flow in Bash: Using Functions Instead of goto
Bash doesn’t have a goto statement, and that’s intentional. The language is designed to encourage cleaner control flow patterns that are easier to debug and maintain. When you feel the urge to jump around in your script, there’s almost always a better approach.
Functions and Early Return
The cleanest pattern is extracting logic into functions and returning early:
check_dependencies() {
if ! command -v curl &>/dev/null; then
return 1
fi
if ! command -v jq &>/dev/null; then
return 1
fi
return 0
}
if ! check_dependencies; then
echo "Error: Missing required tools" >&2
exit 1
fi
Return codes separate concerns cleanly without any jumping. The calling code decides what to do with the result.
For complex validation chains, you can return early from within nested conditions:
validate_input() {
local file="$1"
[[ -z "$file" ]] && return 1
[[ ! -f "$file" ]] && return 1
[[ ! -r "$file" ]] && return 1
# file is valid, continue
return 0
}
Breaking Out of Nested Loops
Use break with a numeric argument to exit multiple loop levels at once:
for outer in {1..5}; do
for inner in {1..5}; do
if [[ $inner -eq 3 ]]; then
break 2 # Exit both loops
fi
echo "$outer:$inner"
done
done
Similarly, continue with a number skips to the next iteration of an outer loop:
for i in {1..10}; do
for j in {1..5}; do
if (( j == 2 )); then
continue 2 # Skip to next i
fi
echo "$i:$j"
done
done
Error Handling with trap
Use trap to run cleanup code or exit handlers from anywhere in your script:
cleanup() {
rm -f "$tmpfile"
[[ -n "$lockfile" ]] && rm -f "$lockfile"
}
tmpfile="/tmp/work.$$"
lockfile="/var/lock/myapp.lock"
trap cleanup EXIT INT TERM
process_data() {
touch "$lockfile"
echo "Processing..." > "$tmpfile"
if ! validate_file "$tmpfile"; then
return 1
fi
}
process_data || exit 1
The trap runs on normal exit, interrupts (Ctrl+C), and termination signals. This is far more flexible than goto-style jumping.
Case Statements for Branching
When you need conditional logic that spans distinct code sections, use case:
action="${1:-help}"
case "$action" in
start)
systemctl start myservice
;;
stop)
systemctl stop myservice
;;
restart)
systemctl restart myservice
;;
status)
systemctl status myservice
;;
*)
echo "Usage: $0 {start|stop|restart|status}" >&2
exit 1
;;
esac
This is clearer than a series of if-elif statements and scales well as you add more conditions.
State Machines for Complex Workflows
For multi-step processes with conditional branching between stages, use an explicit state variable:
state="init"
config_valid=false
while [[ $state != "done" ]]; do
case "$state" in
init)
echo "Initializing..."
setup_resources || state="error"
[[ $state != "error" ]] && state="validate"
;;
validate)
echo "Validating configuration..."
if validate_config; then
config_valid=true
state="execute"
else
state="error"
fi
;;
execute)
echo "Running main logic..."
run_main_logic || state="error"
[[ $state != "error" ]] && state="done"
;;
error)
echo "Error: Configuration invalid or task failed" >&2
state="done"
return 1
;;
esac
done
This pattern is explicit, testable, and much easier to debug than implicit jumps.
Pipeline Error Handling
When running commands in sequence, check returns incrementally:
process_file() {
local infile="$1" outfile="$2"
if ! validate_syntax "$infile"; then
echo "Error: Invalid input file" >&2
return 1
fi
if ! transform_data "$infile" > "$outfile"; then
echo "Error: Transformation failed" >&2
rm -f "$outfile"
return 1
fi
if ! verify_output "$outfile"; then
echo "Error: Output verification failed" >&2
rm -f "$outfile"
return 1
fi
return 0
}
Each step returns early on failure, with cleanup handled at that point.
Avoiding eval-Based Workarounds
Older code sometimes uses eval to simulate goto with dynamically generated code. Don’t do this:
# Anti-pattern - avoid
eval "
if [[ \$condition ]]; then
do_something
exit 0
fi
"
Problems with this approach:
- Undebuggable: Stack traces become meaningless and errors are cryptic
- Security risk: Variable expansion in eval strings can execute unintended code
- Unmaintainable: Quoting and escaping quickly become fragile
- Runtime failures: Syntax errors in the generated code fail at execution, not parse time
Summary
Bash provides multiple clean patterns for control flow:
- Functions + return codes for isolated logic with error handling
- break/continue for escaping nested loops
- trap for cleanup and error handlers
- case statements for multi-way branching
- State machines for complex workflows
- Early returns to bail out of deep call stacks
These patterns are standard in bash, produce maintainable code, and scale well. Avoid eval-based workarounds entirely—they create more problems than they solve. When you feel the need for goto, stop and ask what control flow pattern actually solves your problem.
