When to use bash, and when not to
bash works well for:
- Chaining CLI utilities: pipes, filtering, redirects, exit-code checks.
- Install, deploy, and CI scripts: run a sequence of commands and stop on failure.
- System administration automation.
Rewrite in Python or Go when:
- You need arrays of objects, maps, or JSON parsing.
- The script exceeds roughly 150 lines, or has functions with multiple parameters.
- You need concurrency, tests, or types.
Once you reach the point where you want a bash array containing a dictionary, stop writing bash.
Minimal safe skeleton
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# code here
What each line does:
#!/usr/bin/env bash: portable shebang./bin/bashmay not exist on macOS, BSD, or containers using busybox.set -e: exit on the first error (any command with a non-zero exit code).set -u: exit when an undeclared variable is used.set -o pipefail: the pipeline's exit code equals the exit code of the last failed command, not the last command. Without this,false | truereturns success.IFS=$'\n\t': split only on newline and tab, not spaces. Protects against filenames with spaces infor f in $(ls)-style loops.
Without set -euo pipefail, errors are silently ignored and the script keeps doing the wrong thing.
Variables
name="serge" # no spaces around =
echo "$name" # ALWAYS quote, or it breaks on spaces
echo "${name}_user" # ${} to delimit the variable nameport=8080
echo "URL: http://localhost:${port}/api"Quoting:
"...": interpolates$var,$(...), and escapes. The default. Use it always.'...': literal, no substitution.- Unquoted: triggers word splitting and glob expansion. Dangerous with spaces and
*.
Rule: always wrap variables in "$var", even when it seems unnecessary.
Command substitution: $(cmd)
today=$(date +%F)
files=$(find /var/log -name '*.log' | wc -l)
echo "Today: ${today}, log files: ${files}"The old backtick form `cmd` works too, but $() is better:
- Nesting is straightforward:
$(echo $(date)). - Fewer escaping issues.
Conditionals: [[ ]] and (( ))
bash has three syntaxes for tests. Use the modern ones.
Strings and files: [[ ]]
if [[ -f /etc/passwd ]]; then echo "exists"; fi
if [[ -z "$var" ]]; then echo "empty"; fi
if [[ "$user" == "root" ]]; then echo "root"; fi
if [[ "$msg" =~ ^ERROR ]]; then echo "regex match"; fi
| Test | What it checks |
|---|---|
-f path | regular file exists |
-d path | directory exists |
-e path | path exists (any type) |
-r path | readable |
-w path | writable |
-x path | executable |
-z "$s" | string is empty |
-n "$s" | string is non-empty |
"$a" == "$b" | strings are equal |
"$a" != "$b" | strings are not equal |
"$s" =~ regex | regex match |
The old [ ... ] (POSIX) form works too, but [[ ]] is richer and does not require quoting variables. Use [[ ]] in all bash scripts, except when targeting /bin/sh.
Numbers: (( ))
if (( count > 100 )); then echo "many"; fi
if (( $# < 2 )); then echo "need 2+ args"; exit 1; fi
total=$(( a + b )) # arithmetic
Inside (( )), $ on variables is optional and quoting is not needed. This is the arithmetic context.
Loops
# for-in over a list
for host in web1 web2 web3; do
ssh "$host" "uptime"
done
# iterate over files with a glob, not $(ls)
for f in /var/log/*.log; do
echo "Processing $f"
done
# while-read line by line (the standard way to process files)
while IFS= read -r line; do
echo "Got: $line"
done < input.txt
# C-style
for ((i=0; i<10; i++)); do
echo "$i"
done
while IFS= read -r is the only correct way to read a file line by line. IFS= prevents trimming of leading and trailing whitespace; -r disables backslash escape processing. Without both, lines containing tabs or \n will be corrupted.
Never use for f in $(ls *.txt). It breaks on filenames with spaces and causes double glob expansion. Use for f in *.txt directly.
Functions
greet() { local name="${1:-world}" # default if $1 is not passedecho "Hello, $name!"
}
greet # -> Hello, world!
greet "serge" # -> Hello, serge!
Arguments: $1, $2, ..., $@ (all), $# (count), $0 (script name).
local is required for every variable inside a function. Without local, variables are global and corrupt state outside the function. This is the most common bash mistake.
Redirect and pipe
cmd > file # stdout -> file (overwrite)
cmd >> file # stdout -> file (append)
cmd 2> file # stderr -> file
cmd > file 2>&1 # stdout and stderr -> file (order matters!)
cmd &> file # bash shortcut for the same
cmd < file # stdin from file
cmd1 | cmd2 # stdout of cmd1 -> stdin of cmd2
cmd1 |& cmd2 # stdout and stderr -> stdin of cmd2
cmd > /dev/null 2>&1 # discard all output
Heredoc for multi-line input:
cat <<EOF > /etc/myapp.conf
port=8080
user=$USER # $USER is expanded (no quotes around EOF)
EOF
cat <<'EOF' > /etc/myapp.conf
port=$PORT # $PORT is NOT expanded (quotes around EOF)
EOF
Exit codes: the primary error mechanism
Every command returns an integer 0-255. 0 = success; anything else is an error.
cmd && echo "ok" # runs only if cmd succeeded
cmd || echo "failed" # runs only if cmd failed
cmd || exit 1 # exit if cmd failed
cmd1; cmd2 # cmd2 runs regardless
echo $? # exit code of the last command
Checking multiple commands:
if cmd1 && cmd2; then echo "both ok"; fi
With set -e, use cmd || true to say "this may fail, keep going."
Argument parsing with getopts
while getopts ":vh:f:" opt; do
case $opt in
v) verbose=1 ;;
h) host="$OPTARG" ;;
f) file="$OPTARG" ;;
\?) echo "Unknown: -$OPTARG"; exit 1 ;;
:) echo "-$OPTARG needs argument"; exit 1 ;;
esac
done
shift $((OPTIND-1)) # shift positional parameters past the flags
bash does not natively support long options (--verbose). Use getopt (an external utility) or switch to Python or Go.
Debugging
bash -n script.sh # check syntax only, do not run
bash -x script.sh # trace each command as it executes
set -x # enable tracing mid-script
set +x # disable tracing
In trace mode, each command is printed to stderr with a + prefix before it runs. This is the fastest way to find where a script fails.
shellcheck: the required linter
Run any non-trivial script through shellcheck. It catches:
- Unquoted
$varreferences. $(ls)in a for loop.[ ... -a ... ]instead of[[ ... && ... ]].- Mismatched quotes.
- Global variables inside functions.
sudo apt install shellcheck # Debian/Ubuntu
sudo dnf install ShellCheck # Fedora
shellcheck script.sh
Integrate shellcheck into CI and pre-commit hooks. Without it, bash projects accumulate hidden bugs.
Common traps
- Unquoted
$var:rm $filewhenfile="my doc.txt"executesrm my doc.txt(two separate arguments, two victims). Always writerm "$file". set -edoes not catch everything: not insideif, not after||, not in a pipeline withoutpipefail, not in a subshell. Add explicit||after critical commands.cdwithout a failure check: writecd /opt/app && do_stuff, notcd /opt/app; do_stuff. Ifcdfails, the second form runsdo_stuffin the current directory.trapfor cleanup:trap 'rm -f "$tmp"' EXITremoves temporary files even on kill -2.bash -cvssh -c: on systems wheresh = dash(Debian/Ubuntu), bash syntax does not work insh -c.