cheat-sheets

ShellCheck and shfmt

shellcheck is a static analyser that finds quoting bugs, portability problems and common mistakes in shell scripts; shfmt is a formatter for the same scripts. Run both locally, in pre-commit and in CI. Language reference: Bash; patterns that already pass: Bash Recipes.

Documentation: ShellCheck wiki (each code has a page at https://www.shellcheck.net/wiki/SC2086), shfmt

Install #

COMMANDDESCRIPTION
sudo apt install shellcheck shfmtDebian / Ubuntu (shfmt is in Ubuntu 22.04+ and Debian 12+)
brew install shellcheck shfmtmacOS / Linuxbrew
go install mvdan.cc/sh/v3/cmd/shfmt@latestshfmt from source
docker run --rm -v "$PWD:/mnt" koalaman/shellcheck:stable script.shShellCheck via Docker
docker run --rm -v "$PWD:/mnt" -w /mnt mvdan/shfmt -d .shfmt via Docker
pipx install shellcheck-pyShellCheck binary from PyPI (no system package needed)
shellcheck --version, shfmt --versionVerify

ShellCheck Usage #

COMMANDDESCRIPTION
shellcheck script.shLint one file
shellcheck *.shLint several
shellcheck -x script.shFollow sourced files
shellcheck -s bash script.shForce the dialect (sh, bash, dash, ksh)
shellcheck -S warning script.shMinimum severity: error, warning, info, style
shellcheck -e SC2034,SC2154 script.shExclude codes
shellcheck -o all script.shEnable all optional checks
shellcheck -o require-variable-braces script.shEnable one optional check
shellcheck --list-optionalShow the optional checks
shellcheck -f gcc script.shOutput format: tty, gcc, json, json1, checkstyle, diff, quiet
shellcheck -f diff script.sh | git applyApply the auto-fixes it can offer
git ls-files -z '*.sh' | xargs -0 -r shellcheckLint every tracked script
shellcheck -P SCRIPTDIR -x script.shLook for sourced files relative to the script

Exit code is 0 when clean and non-zero when anything at or above the severity is found, so it works directly as a CI gate.

Common Codes and Fixes #

CODEPROBLEMFIX
SC2086Unquoted variable: word splitting and globbing"$var"
SC2046Unquoted command substitution"$(cmd)"
SC2068Unquoted $@ or ${arr[@]}"$@", "${arr[@]}"
SC2164cd can fail and the script continuescd dir || exit 1
SC2155local x=$(cmd) hides the exit statuslocal x; x="$(cmd)"
SC2162read without -r mangles backslashesread -r line
SC2006Backtick command substitution$(cmd)
SC2181Checking $? indirectlyif cmd; then instead of cmd; if [ $? -eq 0 ]
SC2002Useless catgrep foo file or < file cmd
SC2012Parsing ls outputUse a glob or find -print0
SC2015A && B || C is not if/else (C runs if B fails)Use if A; then B; else C; fi
SC2016Expression in single quotes will not expandUse double quotes if you meant expansion; ignore for awk/sed programs
SC2034Variable appears unusedRemove, export it, or add a disable directive
SC2154Variable referenced but never assignedAssign it or declare where it comes from
SC2148No shebangAdd #!/usr/bin/env bash or # shellcheck shell=bash
SC2115rm -rf "$dir/" can become rm -rf / when emptyrm -rf "${dir:?}/"
SC2207arr=($(cmd)) splits badlymapfile -t arr < <(cmd)
SC2129Several redirects to one file{ cmd1; cmd2; } >> file
SC2001sed for a simple substitution${var//old/new}
SC2064trap "..." expands variables nowUse single quotes: trap 'rm "$f"' EXIT
SC2145Mixing string and array in one argument"$@" on its own
SC2317Command appears unreachable (often a function only called by trap)Add a disable directive for that function
SC1090 / SC1091Cannot follow a sourced file# shellcheck source=path, or run with -x
SC3010 / SC3037Bashism in a #!/bin/sh script ([[, echo -e)Use POSIX syntax or switch the shebang to bash

Directives #

Placed on the line above the command (or at the top of the file for the whole file).

bash
#!/usr/bin/env bash
# shellcheck shell=bash
# shellcheck disable=SC2034          # whole file when placed before the first command

# shellcheck disable=SC2086
cmd $unquoted_on_purpose             # next command only

# shellcheck source=lib/common.sh
source "$DIR/lib/common.sh"          # tell it where the file is

# shellcheck source=/dev/null
source "$HOME/.profile"              # do not try to follow

# shellcheck disable=SC2317
cleanup() { rm -f "$tmp"; }          # only used via trap
trap cleanup EXIT

Always add a reason in a comment next to a disable.

.shellcheckrc #

Put it in the repo root (or ~/.config/shellcheckrc) so every run, including the editor and CI, uses the same settings.

ini
shell=bash
external-sources=true
source-path=SCRIPTDIR
enable=require-variable-braces
disable=SC2317

shfmt Usage #

FLAGDESCRIPTION
shfmt script.shPrint the formatted file
-wWrite the result back to the file
-dShow a diff and exit non-zero if anything differs (CI check)
-lList files whose formatting differs
-i <N>Indent with N spaces (0 = tabs, the default)
-ciIndent case branches
-bnBinary operators (&&, |) may start a line
-srAdd a space after redirect operators (> file)
-kpKeep column alignment padding
-fnPut the function opening brace on the next line
-sSimplify code where safe
-mnMinify
-ln <DIALECT>Language: auto (default), bash, posix, mksh, bats
-fFind shell scripts in the given directories
shfmt -i 2 -ci -w .Format every shell file under the current directory with 2-space indents

Files are detected by extension (.sh, .bash) or shebang. With no flags shfmt reads .editorconfig, which is the easiest way to keep editor and CI in agreement:

ini
# .editorconfig
[*.sh]
indent_style = space
indent_size = 2
switch_case_indent = true
binary_next_line = true

Explicit command-line flags override .editorconfig.

CI #

yaml
# GitHub Actions
name: lint-shell
on: [push, pull_request]
jobs:
  shell:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: ShellCheck
        run: git ls-files -z '*.sh' | xargs -0 -r shellcheck -x
      - name: shfmt
        run: |
          sudo apt-get install -y shfmt
          git ls-files -z '*.sh' | xargs -0 -r shfmt -d

Pin action versions with a digest or let Renovate manage them. ShellCheck is already installed on GitHub-hosted Ubuntu runners; shfmt is not.

yaml
# Woodpecker CI
steps:
  - name: shell-lint
    image: koalaman/shellcheck-alpine:stable
    commands:
      - find . -name '*.sh' -not -path './.git/*' -print0 | xargs -0 -r shellcheck -x

Local gate before every commit: pre-commit with the shellcheck-py and shfmt hooks.

Search all sheets

Tip: / or Ctrl/Cmd+K to open, Esc to close.