ShellCheck, Style & POSIX Portability

Let a static analyser find your quoting bugs, adopt a style that survives code review, and know exactly which constructs break when `/bin/sh` is not bash.

advanced 17 min lesson hands-on task included

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:

CodeProblemFix
SC2086Unquoted variable — word splitting and globbing"$var"
SC2046Unquoted command substitution"$(cmd)"
SC2181Checking $? instead of the command directlyif cmd; then
SC2155local x=$(cmd) masks the exit statusDeclare and assign separately
SC2164cd without checking it workedcd "$d" || exit
SC2115rm -rf "$dir/" where $dir may be emptyrm -rf "${dir:?}/"
SC2006Legacy backticks$(...)
SC2148Missing shebangAdd one
SC2034Variable assigned but never usedUsually a typo elsewhere
SC2016$var inside single quotes will not expandOften 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/"
}
RuleRationale
Indent 2 or 4 spaces, never tabsTabs render differently everywhere. Pick one and configure shfmt.
lower_snake_case for locals, UPPER_SNAKE for globals/envInstantly signals scope.
readonly / local -r for constantsCatches accidental reassignment.
local for every function variableBash defaults to global; forgetting local is a real bug source.
$(...) never backticksNests, and quoting behaves.
[[ ]] over [ ] in bashNo word-splitting surprises, supports =~ and &&.
Function header commentsGlobals, arguments, outputs, returns.
main "$@" at the bottomEverything is a function; execution order is explicit.
Line length ~80–100Reviewable 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 featurePOSIX alternative
[[ ]][ ] with careful quoting
(( )), $(( )) arithmetic commands$(( )) is POSIX; (( )) as a command is not
Arrays, associative arraysPositional parameters, or another language
localNot POSIX — but supported by dash and ash in practice
source.
function name()name()
+= appendvar="${var}more"
${var^^} / ${var,,}tr '[:lower:]' '[:upper:]'
<<< here-stringsprintf '%s\n' "$x" | or a here-doc
<(...) process substitutionTemp files with mktemp
mapfile / readarraywhile read loop
echo -e, echo -nprintf — genuinely more portable
$RANDOM, $EPOCHSECONDSawk, date +%s
trap ... ERR, set -o pipefailNot POSIX; check exit codes manually

Choosing:

The decision is simple and rarely agonised over correctly:

  • Targeting a known Linux fleet? Use #!/usr/bin/env bash and every feature you want. Bash is installed everywhere that matters.
  • Shipping into containers — Alpine, distroless, busybox? Write POSIX sh, because bash may genuinely not be present.
  • Writing an installer that runs anywhere? POSIX sh, tested under dash.

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.