Most shell bugs are known bugs — the same dozen mistakes, made repeatedly, for decades. A static analyser catches them in seconds, which makes running one the highest-leverage habit in this entire module.
Topic 1: ShellCheck
sudo apt install shellcheck # Debian/Ubuntu
brew install shellcheck # macOS
docker run --rm -v "$PWD:/mnt" koalaman/shellcheck:stable myscript.sh
shellcheck script.sh
shellcheck -s bash script.sh # force a dialect
shellcheck -S error script.sh # errors only
shellcheck -f gcc script.sh # gcc-style output for editors/CI
shellcheck -x script.sh # FOLLOW sourced files
-x matters for any multi-file project: without it, ShellCheck cannot see what your libraries define and reports false “unassigned variable” warnings.
The warnings that matter most:
| Code | Problem | Fix |
|---|---|---|
| SC2086 | Unquoted variable — word splitting and globbing | "$var" |
| SC2046 | Unquoted command substitution | "$(cmd)" |
| SC2181 | Checking $? instead of the command directly | if cmd; then |
| SC2155 | local x=$(cmd) masks the exit status | Declare and assign separately |
| SC2164 | cd without checking it worked | cd "$d" || exit |
| SC2115 | rm -rf "$dir/" where $dir may be empty | rm -rf "${dir:?}/" |
| SC2006 | Legacy backticks | $(...) |
| SC2148 | Missing shebang | Add one |
| SC2034 | Variable assigned but never used | Usually a typo elsewhere |
| SC2016 | $var inside single quotes will not expand | Often intentional |
SC2086 alone accounts for a large share of all findings — it is the quoting lesson, enforced.
Suppressing, honestly:
# shellcheck disable=SC2086 # word splitting is intended: $flags is a list of options
kill $running_pids
Put the directive on the line immediately above the offending line, and always with a reason. A bare disable= with no explanation is how a real bug gets grandfathered in. A file-level disable at the top applies to the whole file and should be rare.
In CI:
- name: Lint shell scripts
run: |
find . -name '*.sh' -print0 | xargs -0 shellcheck -x -f gcc
Or a pre-commit hook:
repos:
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.10.0
hooks:
- id: shellcheck
Pair it with shfmt for formatting so review comments are about logic, not indentation:
shfmt -i 4 -ci -w script.sh # 4-space indent, indent case bodies, write
shfmt -d script.sh # diff mode — fails CI if unformatted
Topic 2: Style That Survives Review
The Google Shell Style Guide is the most widely adopted, and its rules are worth following even where you disagree, because consistency is the point.
#!/usr/bin/env bash
#
# Sync build artifacts to a target host.
# Usage: deploy [-n] [-v] TARGET
#
set -Eeuo pipefail
#######################################
# Upload the build directory to a host.
# Globals:
# BUILD_DIR
# Arguments:
# $1 - target hostname
# Outputs:
# Progress to stderr
# Returns:
# 0 on success, non-zero on rsync failure
#######################################
upload::to_host() {
local -r target=$1
local -r src="${BUILD_DIR:?BUILD_DIR is required}"
[[ -d $src ]] || { echo "missing build dir: ${src}" >&2; return 1; }
rsync -az --delete "${src}/" "${target}:/srv/app/"
}
| Rule | Rationale |
|---|---|
| Indent 2 or 4 spaces, never tabs | Tabs render differently everywhere. Pick one and configure shfmt. |
lower_snake_case for locals, UPPER_SNAKE for globals/env | Instantly signals scope. |
readonly / local -r for constants | Catches accidental reassignment. |
local for every function variable | Bash defaults to global; forgetting local is a real bug source. |
$(...) never backticks | Nests, and quoting behaves. |
[[ ]] over [ ] in bash | No word-splitting surprises, supports =~ and &&. |
| Function header comments | Globals, arguments, outputs, returns. |
main "$@" at the bottom | Everything is a function; execution order is explicit. |
| Line length ~80–100 | Reviewable in a split diff. |
The main pattern is worth adopting wholesale:
main() {
parse_args "$@"
validate
do_the_work
}
main "$@"
It makes the script readable top-to-bottom, keeps everything testable in isolation, and means sourcing the file for tests does not execute it.
Topic 3: POSIX vs Bash
#!/bin/sh does not mean bash. On Debian and Ubuntu it is dash; on Alpine it is busybox ash. Both are far smaller and lack most of what people think of as shell scripting.
ls -l /bin/sh # what is it on THIS system?
What breaks under /bin/sh:
| Bash feature | POSIX alternative |
|---|---|
[[ ]] | [ ] with careful quoting |
(( )), $(( )) arithmetic commands | $(( )) is POSIX; (( )) as a command is not |
| Arrays, associative arrays | Positional parameters, or another language |
local | Not POSIX — but supported by dash and ash in practice |
source | . |
function name() | name() |
+= append | var="${var}more" |
${var^^} / ${var,,} | tr '[:lower:]' '[:upper:]' |
<<< here-strings | printf '%s\n' "$x" | or a here-doc |
<(...) process substitution | Temp files with mktemp |
mapfile / readarray | while read loop |
echo -e, echo -n | printf — genuinely more portable |
$RANDOM, $EPOCHSECONDS | awk, date +%s |
trap ... ERR, set -o pipefail | Not POSIX; check exit codes manually |
Choosing:
The decision is simple and rarely agonised over correctly:
- Targeting a known Linux fleet? Use
#!/usr/bin/env bashand every feature you want. Bash is installed everywhere that matters. - Shipping into containers — Alpine, distroless,
busybox? Write POSIXsh, because bash may genuinely not be present. - Writing an installer that runs anywhere? POSIX
sh, tested underdash.
Never write #!/bin/sh and then use bash syntax. It works on your machine (where /bin/sh may be bash) and fails in the container. If you need bash, say bash.
# Verify a script really is POSIX
shellcheck -s sh script.sh
dash -n script.sh # syntax check under dash
checkbashisms script.sh # from the devscripts package
The macOS bash 3.2 trap:
Apple ships bash 3.2 from 2007, for GPLv3 licensing reasons. So even #!/bin/bash on a Mac lacks:
mapfile/readarray- Associative arrays (
declare -A) ${var^^}case conversion- Negative array indices
wait -n
# Guard when it matters
if (( BASH_VERSINFO[0] < 4 )); then
echo "bash 4+ required; found ${BASH_VERSION}" >&2
exit 1
fi
#!/usr/bin/env bash helps here, because Homebrew’s bash 5 lands earlier in PATH than /bin/bash.
Topic 4: A Pre-Merge Checklist
bash -n script.sh # 1. syntax
shellcheck -x script.sh # 2. static analysis
shfmt -d script.sh # 3. formatting
bats tests/ # 4. tests
grep -n 'set -Eeuo pipefail' script.sh # 5. strict mode present?
grep -nE '\$[A-Za-z_][A-Za-z0-9_]*' script.sh | grep -v '"' # 6. unquoted vars
Wired into CI, that is five minutes of setup that catches the entire class of bug this module has been describing:
name: shell
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: ShellCheck
run: |
shopt -s globstar
shellcheck -x -f gcc **/*.sh
- name: Format check
run: shfmt -d -i 4 -ci .
Try it yourself: Run shellcheck on the oldest script in your repository. Count the findings, then fix only the SC2086s and re-run — that one rule usually accounts for the majority.
Common mistake: Adding a file-level # shellcheck disable=SC2086 to silence a noisy script. It disables the check for every line, including the ones that are genuinely broken. Fix the quoting, or disable per-line with a stated reason.