Every time you ssh into a server, trigger a CI job, or deploy a container, Bash is somewhere in the chain. Shell scripting is not glamorous but it is the most universally portable automation tool that exists — every Linux machine and macOS terminal runs Bash, and the knowledge you build applies across cloud providers, CI systems, and decades of infrastructure. The problem is that most Bash tutorials teach you how to write scripts; almost none teach you how to write scripts that work reliably. This guide focuses on correctness over cleverness.
What changed in 2026
- Bash 5.2/5.3 added minor improvements — associative array improvements and
${var@Q} quoting are useful but the language core is stable. Learn it once and it stays.
- ShellCheck 0.10 added more pattern checks and better integration with VS Code and Neovim LSP; it should be in every CI pipeline.
- POSIX portability matters more again. Alpine Linux (musl) uses
ash/dash as /bin/sh. Writing POSIX-compatible scripts means they run in minimal containers without surprises.
- GitHub Actions and GitLab CI are the dominant execution environments. Understanding how
set -e interacts with if statements is directly relevant to debugging CI failures.
mise (formerly rtx) replaces nvm/rbenv/pyenv. The shim-based version manager ecosystem has consolidated; knowing how to manipulate PATH is essential for managing it.
The learning path
Week 1: the safety header and quoting
Every script you write should start with:
#!/usr/bin/env bash
set -euo pipefail
IFS=#39;\n\t'
set -e: exit on any command failure (with important exceptions).
set -u: treat unset variables as errors — catches typos like $NAEM silently doing nothing.
set -o pipefail: a pipeline like cmd1 | cmd2 fails if cmd1 fails (without this, only cmd2's exit code counts).
IFS=#39;\n\t': prevents word-splitting on spaces, which breaks filenames with spaces.
Quoting rules — memorise these:
name="hello world"
echo $name # BAD: splits into two arguments
echo "$name" # GOOD: one argument with the space
files=$(ls *.txt)
for f in $files; do echo "$f"; done # BAD: breaks on spaces
for f in *.txt; do echo "$f"; done # GOOD: glob directly
Week 2: conditionals and functions
#!/usr/bin/env bash
set -euo pipefail
# Prefer [[ ]] over [ ] for conditionals in Bash
is_even() {
local n="$1"
(( n % 2 == 0 ))
}
backup_file() {
local src="$1"
local dest="${2:-${src}.bak}" # default second arg
if [[ ! -f "$src" ]]; then
echo "ERROR: $src not found" >&2
return 1
fi
cp -- "$src" "$dest"
echo "Backed up $src → $dest"
}
backup_file "config.yaml"
backup_file "data.csv" "/tmp/data.csv.bak"
local is essential — without it, all variables are global and functions corrupt each other's state.
Week 3: loops, arrays, and argument parsing
#!/usr/bin/env bash
set -euo pipefail
# Indexed arrays
fruits=("apple" "banana" "cherry")
for fruit in "${fruits[@]}"; do
echo "$fruit"
done
# Associative arrays (Bash 4+)
declare -A colours
colours[red]="#FF0000"
colours[green]="#00FF00"
echo "${colours[red]}"
# Argument parsing with getopts
usage() { echo "Usage: $0 [-v] [-o outfile] infile" >&2; exit 1; }
verbose=false
outfile="output.txt"
while getopts ":vo:" opt; do
case $opt in
v) verbose=true ;;
o) outfile="$OPTARG" ;;
*) usage ;;
esac
done
shift $(( OPTIND - 1 ))
infile="${1:-}"
[[ -n "$infile" ]] || usage
Week 4: debugging and ShellCheck
# Trace execution (print each command before running it)
set -x
# Check a specific section
set -x
risky_command "$arg"
set +x
# ShellCheck in CI (install: apt install shellcheck / brew install shellcheck)
shellcheck my_script.sh
ShellCheck catches: unquoted variables, [ ] vs [[ ]] confusion, local missing, incorrect exit code checks, and dozens more patterns.
Comparison: Bash vs alternatives for automation in 2026
| Task |
Bash |
Python |
Go |
| System glue / piping |
Best |
Good |
Awkward |
| String/text processing |
Awkward |
Excellent |
Excellent |
| JSON parsing |
Poor (jq helps) |
Excellent |
Excellent |
| Error handling |
Fragile |
Robust |
Robust |
| Cross-platform |
Linux/macOS only |
Everywhere |
Single binary |
| Complex data structures |
Painful |
Natural |
Natural |
| Startup time |
Instant |
~50 ms |
~5 ms |
How to pick your tooling
- Bash for: deploy scripts, git hooks, CI step glue, file operations, environment setup, anything under ~100 lines.
- Python for: scripts with loops-with-logic, JSON/YAML, HTTP calls, anything you want to unit test.
- Go for: standalone CLI tools you distribute as binaries with no runtime dependency.
Common mistakes
No set -euo pipefail. The script silently swallows errors and continues. You find out at 2 a.m. when the production database is half-migrated.
Unquoted variables. rm -rf $dir/* with dir="" removes your current directory's contents. Always quote.
Parsing ls output. for f in $(ls) breaks on spaces and symlinks. Use globs: for f in *.
Checking exit codes manually with $?. Just use if command; then — it reads the exit code naturally.
Writing 500-line scripts. At that size, Python with subprocess is more maintainable and testable. Know when to stop.
What to skip
bash -x in production logging — it leaks secrets from environment variables into logs.
- Complex arithmetic in Bash — use
bc, awk, or Python; Bash integers overflow and have no floats.
- Heredocs for templating — use a real templating tool (
envsubst, jinja2) before your heredoc grows past 20 lines.
FAQ
Should I learn Bash or Python for DevOps automation?
Both — they are complementary. Bash for short system glue; Python for anything with logic. Most DevOps engineers use Bash daily and Python weekly.
Is Zsh different enough to matter?
Zsh is your interactive shell; Bash is what your scripts use. Write scripts with #!/usr/bin/env bash and they run in both environments. Learn Zsh enhancements separately for terminal productivity.
How do I handle errors in Bash robustly?
set -euo pipefail plus explicit checks for the commands that legitimately return non-zero (wrap them in if or append || true). For complex error handling, switch to Python.
What is the best way to learn Bash quickly?
Write real scripts for your actual work: automate a deployment step, write a git hook, script a backup. Reading without writing is far less effective for shell scripting.
Where to go next