Use local in functions to avoid variable leaks. Quote all variable expansions ("$var") to prevent word splitting and globbing. Prefer for loops over while read when iterating command output. Always check the exit code of commands inside loops.
✦ Definition~90s read
What is Bash Scripting?
Bash scripting is the glue of Linux DevOps — automating tasks, orchestrating deployments, and parsing logs. Variables store data, loops repeat actions, and functions encapsulate logic. This article covers the production-grade patterns, not the hello-world versions.
★
Think of a bash script as a recipe.
Plain-English First
Think of a bash script as a recipe. Variables are the ingredient amounts — you write them down once and reuse. Loops are like repeating a step for each potato you peel. Functions are sub-recipes you can call from the main recipe. The trick is not to spill ingredients (unquoted variables) or burn the kitchen (infinite loops).
⚙ Browser compatibility
Latest versions — ✓ supported
bash 4.x
bash 5.x
zsh
dash
✓
✓
✓
—
✓
✓
✓
—
✓
✓
✓
—
✓
✓
✓
—
Every DevOps engineer has that one script. The one that runs fine in testing but deletes half the production logs at 2am because a variable was empty. Bash is unforgiving — a missing quote, an unset variable, a loop that silently skips errors. This isn't about syntax. It's about writing scripts that don't bite you. You're about to learn the patterns that separate a cron job that runs for years from the one that wakes you up at 3am. By the end, you'll write variables that don't leak, loops that fail fast, and functions that are actually reusable.
Variables: The Silent Killers
Variables seem harmless. Until an unquoted variable with a space in its value splits into two arguments, or an empty variable deletes the wrong directory. The problem is that bash, by default, treats missing variables as empty strings — not errors. That's why rm -rf /$important_dir becomes rm -rf / when $important_dir is unset. The fix: always quote expansions and use set -u to make unset variables fatal. Also, use ${var:-default} for safe defaults. In production, every variable expansion is a potential landmine.
safe_variables.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash
# io.thecodeforge — DevOps tutorial
set -euo pipefail # Exit on error, unset variable, pipe failure
log_dir="/var/log/myapp"
# Usedefaultif variable is unset
archive_dir="${ARCHIVE_DIR:-/tmp/archive}"
# Always quote to preserve spaces and special chars
rm -rf "${log_dir:?}" # :? fails if variable is empty or unset
# Array example: safe iteration
files=("file one.txt""file two.txt")
for f in "${files[@]}"; do
echo "Processing: $f"
done
Output
Processing: file one.txt
Processing: file two.txt
⚠ Production Trap: Unquoted Variables
If a filename contains a space, rm $file deletes two files. Always use "$var". The one exception: when you explicitly want word splitting, like for word in $sentence.
Loops: Fail Fast or Fail Forever
Loops are where scripts go to die silently. A for loop over a glob that matches nothing runs once with the literal glob string. A while read loop that hits a permission error continues as if nothing happened. The fix: check that globs match before looping, and always check exit codes inside loops. Use shopt -s nullglob to make empty globs expand to nothing. For while read, add || break to stop on errors. And never pipe into while read — use process substitution to avoid subshell variable loss.
safe_loops.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/bin/bash
# io.thecodeforge — DevOps tutorial
set -euo pipefail
# Safe glob loop: nullglob prevents literal '*' when no files match
shopt -s nullglob
for config in /etc/myapp/*.conf; do
echo "Processing $config"
# Simulate processing; if it fails, break the loop
process_file "$config" || { echo "Failed on $config"; break; }
done
shopt -u nullglob
# Safewhile read with process substitution (no subshell)
count=0whileIFS= read -r line; do
((count++))
echo "Line $count: $line"
done < <(grep 'ERROR' /var/log/app.log)
echo "Total errors: $count"
Output
Processing /etc/myapp/app.conf
Processing /etc/myapp/db.conf
Line 1: ERROR: connection timeout
Line 2: ERROR: disk full
Total errors: 2
⚠ The Classic Bug: Piped while read
grep pattern file | while read line; do ... done — the count variable inside the loop is lost after the pipe. Use while ... done < <(grep pattern file) instead.
Functions: Encapsulate or Contaminate
Functions in bash are just commands — they run in the same shell unless you explicitly spawn a subshell. That means any variable you set inside a function leaks to the global scope unless you declare it local. The rule: every variable inside a function must be local. Also, functions should return meaningful exit codes (0 for success, non-zero for failure). Use return not exit — exit kills the entire script. And never use function keyword; it's non-portable. Just use name() { ... }.
safe_functions.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/bin/bash
# io.thecodeforge — DevOps tutorial
set -euo pipefail
# Good: local variables, return codes
process_log() {
local logfile="$1"
local errors
errors=$(grep -c 'ERROR'"$logfile") || true # grep returns 1if no match
if [[ "$errors" -gt 100 ]]; then
echo "Too many errors: $errors"return1
fi
echo "Errors: $errors"return0
}
# Call function and check returnif process_log "/var/log/app.log"; then
echo "Log OK"else
echo "Log check failed"
fi
Output
Errors: 42
Log OK
💡Senior Shortcut: Local All The Things
Make it a habit: the first line of every function should be local var1 var2 var3. This prevents accidental global variable pollution that can cause heisenbugs.
When Not to Use Bash Functions
Bash functions are great for grouping commands, but they're terrible for complex logic. If your function has more than 20 lines, or uses arrays with complex manipulation, or needs to return structured data (like a list), switch to Python or a compiled language. Bash functions cannot return arrays — you have to echo and parse, which is fragile. Also, functions are slow in tight loops because each call forks a subshell (unless you use source). For performance-critical loops, inline the logic or use eval carefully.
when_not_to_use.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/bash
# io.thecodeforge — DevOps tutorial
# Bad: function called in tight loop (slow)
slow_function() {
echo "$(( $1 * 2 ))"
}
for i in {1..1000}; do
result=$(slow_function $i) # subshell each iteration
done
# Good: inline or use eval (if safe)
for i in {1..1000}; do
result=$(( i * 2 ))
done
🔥Performance Trap: Subshell in Loop
Calling a function with $() in a loop creates a subshell every iteration. For 1000 iterations, that's 1000 forks. Use eval or inline the logic for speed.
Debugging Bash Scripts in Production
When a script fails at 3am, you need fast diagnostics. First, enable set -x to trace every command. But be careful — it can leak secrets in logs. Use PS4='+ $BASH_SOURCE:$LINENO ' to get file and line numbers. Second, use trap to catch errors and print a stack trace. Third, log to syslog with logger instead of echo — it's timestamped and searchable. Never use echo for debugging in production; it mixes with normal output.
debug_script.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#!/bin/bash
# io.thecodeforge — DevOps tutorial
set -euo pipefail
# Debug mode: trace with file and line
PS4='+ $BASH_SOURCE:$LINENO 'if [[ "${DEBUG:-}" == "true" ]]; then
set -x
fi
# Error trap with stack trace
error_handler() {
local line=$1
local command=$2
logger -p user.err "Error at line $line: $command"
exit 1
}
trap 'error_handler $LINENO "$BASH_COMMAND"'ERR
# Simulate a command that might fail
cp /nonexistent/file /tmp/ || true # trap won't fire because of || true
# Real failure
cp /nonexistent/file /tmp/ # this triggers the trap
Output
Error at line 18: cp /nonexistent/file /tmp/
💡Interview Gold: Debugging Without set -x
In production, set -x can flood logs with sensitive data. Use trap DEBUG to log selectively, or redirect debug output to a separate file with exec 5> debug.log; BASH_XTRACEFD=5.
● Production incidentPOST-MORTEMseverity: high
The 4GB Log That Kept Growing
Symptom
A log rotation script ran every hour but never rotated. The log file grew to 4GB and filled the disk, crashing the application.
Assumption
The team assumed the log rotation config was wrong or cron wasn't running.
Root cause
The bash script used a while read loop with a pipe, which created a subshell. The variable rotated was set inside the loop but never visible outside — so the script always thought rotation wasn't needed.
Fix
Replace command | while read line; do ... done with a process substitution: while read line; do ... done < <(command). This keeps the loop in the current shell, preserving variable changes.
Key lesson
Pipes create subshells.
Any variable set inside a piped while read is lost.
Use process substitution or a here-string to keep variables in scope.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
Script fails with 'unbound variable' after adding set -u
→
Fix
1. Identify the unset variable from the error message. 2. Check if it's optional — use ${var:-default}. 3. If it's required, ensure it's set before use.
Symptom · 02
Loop runs once with literal glob string (e.g., '*.txt')
→
Fix
1. Check if nullglob is set: shopt -s nullglob. 2. Verify files exist. 3. Use compgen -G '*.txt' to test glob expansion.
Symptom · 03
Function modifies global variable unexpectedly
→
Fix
1. Check function body for missing local declarations. 2. Add local to all variables. 3. Use declare -p var to inspect variable attributes.
★ Bash Scripting: Variables, Loops, and Functions Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
Variable expansion gives wrong result or error `unbound variable`−
Immediate action
Check if variable is set and quoted
Commands
echo "${var:?}"
set -u; echo "$var"
Fix now
Use ${var:-default} or ensure variable is set before use.
Loop processes no files or wrong files+
Immediate action
Test glob expansion
Commands
ls -la /path/*.conf
shopt -s nullglob; for f in /path/*.conf; do echo "$f"; done
Add local before each variable assignment in the function.
Script exits silently without error message+
Immediate action
Enable error tracing
Commands
bash -x script.sh 2>&1 | head -50
set -e; trap 'echo "Error at line $LINENO"' ERR
Fix now
Add set -euo pipefail and error trap.
Feature
for loop over glob
while read loop
Handles spaces in filenames
Yes, with quoted array expansion
Yes, with IFS= read -r
Subshell risk
None
Yes if piped; use process substitution
Empty input behavior
Literal glob if nullglob off
No iterations (empty input)
Performance
Fast (built-in)
Slower (external command per line)
⚙ Quick Reference
5 commands from this guide
File
Command / Code
Purpose
safe_variables.sh
set -euo pipefail # Exit on error, unset variable, pipe failure
Variables
safe_loops.sh
set -euo pipefail
Loops
safe_functions.sh
set -euo pipefail
Functions
when_not_to_use.sh
slow_function() {
When Not to Use Bash Functions
debug_script.sh
set -euo pipefail
Debugging Bash Scripts in Production
Key takeaways
1
Quote all variable expansions
"$var" — unless you explicitly want word splitting.
2
Use local in every function to prevent variable leaks.
3
Never pipe into while read
use process substitution to keep variables in scope.
4
Enable set -euo pipefail in every script to catch errors early.
5
Functions cannot return arrays; use nameref or global variables with care.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
What happens when you set a variable inside a `while read` loop that is ...
Q02SENIOR
When would you choose a `for` loop over a `while read` loop in a bash sc...
Q03SENIOR
What is the difference between `export`, `local`, and `declare -g` in ba...
Q04JUNIOR
How do you return an array from a bash function?
Q05SENIOR
A cron job runs a bash script that processes logs. One day it stops work...
Q06SENIOR
How would you design a bash script that must process 10,000 files withou...
Q01 of 06SENIOR
What happens when you set a variable inside a `while read` loop that is part of a pipeline, and why?
ANSWER
The variable is set in a subshell and lost after the pipeline completes. Pipelines create subshells for each command. Use process substitution while ... done < <(cmd) to keep the loop in the current shell.
Q02 of 06SENIOR
When would you choose a `for` loop over a `while read` loop in a bash script?
ANSWER
Use for when iterating over a known set (e.g., glob results, array). Use while read when processing line-by-line from a command output or file, especially if lines may contain spaces. for is faster but less safe with spaces.
Q03 of 06SENIOR
What is the difference between `export`, `local`, and `declare -g` in bash? When would you use each?
ANSWER
export makes a variable available to child processes. local restricts scope to the current function. declare -g explicitly sets a global variable even inside a function. Use local inside functions by default, export for environment variables, and avoid declare -g unless you intentionally want to modify a global.
Q04 of 06JUNIOR
How do you return an array from a bash function?
ANSWER
You can't directly. You must echo the array elements and parse them in the caller, or use a global variable (with declare -g). The cleanest pattern is to pass the array name as a reference using nameref (declare -n) in bash 4.3+.
Q05 of 06SENIOR
A cron job runs a bash script that processes logs. One day it stops working. The script has `set -e`. What's the most likely cause and how do you debug it?
ANSWER
A command inside the script failed, causing set -e to exit immediately. Check the cron logs and script output. Add set -x temporarily to trace execution, or wrap risky commands with || true to allow continuation.
Q06 of 06SENIOR
How would you design a bash script that must process 10,000 files without exhausting memory or taking too long?
ANSWER
Use a for loop with nullglob to iterate files. Avoid storing all filenames in an array. Process files one by one. Use xargs -P for parallel processing if CPU-bound. Monitor memory with ulimit and add timeouts with timeout command.
01
What happens when you set a variable inside a `while read` loop that is part of a pipeline, and why?
SENIOR
02
When would you choose a `for` loop over a `while read` loop in a bash script?
SENIOR
03
What is the difference between `export`, `local`, and `declare -g` in bash? When would you use each?
SENIOR
04
How do you return an array from a bash function?
JUNIOR
05
A cron job runs a bash script that processes logs. One day it stops working. The script has `set -e`. What's the most likely cause and how do you debug it?
SENIOR
06
How would you design a bash script that must process 10,000 files without exhausting memory or taking too long?
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
Why should I quote variables in bash?
Quoting prevents word splitting and globbing. Without quotes, $var splits on spaces and expands wildcards. For example, file="my file.txt" then rm $file tries to remove my and file.txt. Always use "$var".
Was this helpful?
02
What's the difference between `for` and `while` loops in bash?
for iterates over a list (e.g., files from a glob, array elements). while repeats as long as a condition is true, often used with read to process lines. Use for when you know the set, while for streaming input.
Was this helpful?
03
How do I return a value from a bash function?
Use return for integer exit codes (0-255). For strings, echo the value and capture with $(). For arrays, use a global variable or nameref (declare -n).
Was this helpful?
04
What does `set -euo pipefail` do and why should I use it?
-e exits on any command failure. -u treats unset variables as an error. -o pipefail makes a pipeline fail if any command fails. Together they prevent silent failures and unset variable bugs. Essential for production scripts.