ShellCheck is a static analysis tool that reads your shell scripts and flags the bugs that don't show up until production: unquoted variables that word-split on a path with spaces, [ $x = "y" ] tests that break on empty input, useless cat pipelines, cd calls whose failure you never check. Install it and point it at a file: shellcheck myscript.sh. It prints each problem with a line number, a one-line explanation, and an SCxxxx code you can look up. It does not run your script; it parses the source and reasons about it, so it's safe on anything and catches whole classes of mistakes that only bite intermittently. Below is the full workflow: install, read the output, suppress the false positives, pick a shell dialect, and wire it into CI so a broken script never merges.
What does ShellCheck do?
ShellCheck is a linter for bash, sh/POSIX, dash, and ksh scripts, written in Haskell by Vidar Holen (the koalaman/shellcheck project). It performs static analysis: it parses the script into an AST and applies hundreds of checks, each identified by a code like SC2086. It reports three severities (error, warning, info) plus style notes, with the offending line, a caret pointing at the token, and a short fix. Run it as shellcheck file.sh; add -s bash to force a dialect, -S error to raise the minimum severity, -f json for machine-readable output, -x to follow sourced files, and -e SC2086 to silence a specific check. Per-line suppression is a # shellcheck disable=SC2086 comment directly above the line. It exits non-zero when it finds anything at or above the severity threshold, which is what makes it a clean CI gate. For the constructs it most often flags, see Bash functions, Bash for loops, and Bash if, else, and elif.
Jump to:
- Install ShellCheck
- Run it and read the output
- The SC codes worth knowing
- Suppressing false positives
- Picking the right shell dialect
- Output formats and CI
- When ShellCheck is wrong (and when it isn't)
- FAQ
- See also
Install ShellCheck
It's packaged everywhere. Pick your platform:
# Debian / Ubuntu
sudo apt install shellcheck
# Fedora
sudo dnf install ShellCheck
# Arch
sudo pacman -S shellcheck
# macOS (Homebrew)
brew install shellcheck
# Anywhere with a static binary release (no package manager)
# download from the GitHub releases page, it's a single self-contained fileVerify the install and check the version:
shellcheck --versionShellCheck - shell script analysis tool
version: 0.10.0
license: GNU General Public License, version 3
website: https://www.shellcheck.net
The static release binary is genuinely standalone: it bundles the Haskell runtime, so you can drop it into a CI image or a locked-down box with no package manager and it just runs. That matters for the CI use case below.
Run it and read the output
Point it at a script. Here's a small file with three of the most common mistakes:
#!/bin/bash
echo "Cleaning up $TMPDIR"
rm -rf $TMPDIR/*
cd /var/log
cat access.log | grep "404"shellcheck cleanup.shIn cleanup.sh line 3:
rm -rf $TMPDIR/*
^------^ SC2115 (warning): Use "${var:?}" to ensure this never expands to / .
^------^ SC2086 (info): Double quote to prevent globbing and word splitting.
In cleanup.sh line 4:
cd /var/log
^---------^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
In cleanup.sh line 5:
cat access.log | grep "404"
^-- SC2002 (style): Useless cat. Consider 'grep ... < file' or 'cmd file | ..'.
For more information:
https://www.shellcheck.net/wiki/SC2115 -- Use "${var:?}" to ensure thi...
https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent glob...
https://www.shellcheck.net/wiki/SC2164 -- Use 'cd ... || exit' or 'cd ...
Read it top to bottom. Each finding has a code, a severity in parentheses, and the fix in the message itself. The SC2115 one is not pedantry: if TMPDIR is unset, rm -rf $TMPDIR/* becomes rm -rf /*. Every line in that output is a real bug or a real hazard, and the fixed script is short:
#!/bin/bash
echo "Cleaning up ${TMPDIR:?}"
rm -rf "${TMPDIR:?}"/*
cd /var/log || exit 1
grep "404" < access.logRun shellcheck again and it's silent. Silent means clean.
The SC codes worth knowing
There are hundreds, but a handful account for most of what you'll see. Each code has a wiki page at shellcheck.net/wiki/SCxxxx explaining the rationale and the fix.
| Code | What it catches | The fix |
|---|---|---|
| SC2086 | Unquoted variable: word-splits and globs | Quote it: "$var" |
| SC2046 | Unquoted command substitution word-splits | Quote it: "$(cmd)" |
| SC2164 | cd whose failure is unchecked | `cd dir |
| SC2115 | rm -rf $x/ can expand to / | rm -rf "${x:?}"/ |
| SC2006 | Legacy backtick substitution | Use $(...) |
| SC2002 | Useless cat into a pipe | Redirect: cmd < file |
| SC2068 | Unquoted $@ re-splits arguments | Use "$@" |
| SC2034 | Variable assigned but never used | Remove it, or confirm it's a false positive |
| SC1090 | Can't follow a dynamic source path | -x, or a # shellcheck source= directive |
SC2086 is the one you'll meet most, and it's the most worth internalizing: an unquoted variable expansion is word-split on $IFS and then glob-expanded. On a value like My Documents/*.txt that silently turns one argument into many. Quoting is almost always correct; the rare case where you genuinely want splitting (passing a string of separate flags) is exactly where a disable directive belongs.
Suppressing false positives
ShellCheck is conservative, so it sometimes flags an intentional construct. There are three scopes for silencing a check, smallest first.
A single line, suppress with a comment directly above it:
# shellcheck disable=SC2086
rm -f $files # $files is a deliberately-unquoted list of glob patternsA whole file, put the directive near the top (after the shebang, before any code):
#!/bin/bash
# shellcheck disable=SC2034Across an entire run, exclude a code on the command line:
shellcheck -e SC2086 -e SC2046 script.shPrefer the narrowest scope that works. A per-line disable documents this specific line is intentional and still catches the same mistake elsewhere in the file. A blanket -e SC2086 for a whole repo is how you end up shipping the unquoted-variable bug ShellCheck exists to catch. When you do disable something, leave the trailing comment explaining why; future-you will not remember.
For a project-wide default that lives outside any script, drop a .shellcheckrc in the repo root (or your home directory):
# .shellcheckrc
disable=SC2086
enable=require-variable-braces
ShellCheck reads it for every file in or below that directory, which is the right place for a genuine project convention rather than a one-off. The SHELLCHECK_OPTS environment variable does the same for a shell session (export SHELLCHECK_OPTS="-e SC2086"). Both are still blunt instruments: a repo-wide disable hides the check everywhere, so reserve them for codes that truly never apply to your codebase, not for muting noise.
For the dynamic-source case (SC1090/SC1091), point ShellCheck at the file instead of disabling the check:
# shellcheck source=lib/common.sh
source "$CONFIG_DIR/common.sh"Now it follows lib/common.sh for the analysis even though the runtime path is computed.
Picking the right shell dialect
ShellCheck reads the shebang to decide which dialect to apply. #!/bin/bash enables Bash-only features; #!/bin/sh enforces POSIX and flags Bashisms like [[ ]], arrays, and local. That second behavior is the point: if your script must run under dash (the default /bin/sh on Debian and Ubuntu), ShellCheck tells you which Bash habits won't survive.
Override the detected dialect when the shebang lies or is missing:
shellcheck -s sh deploy.sh # force POSIX checks
shellcheck -s bash deploy.sh # force Bash checksOr set it inside the file with a directive, which travels with the script:
#!/bin/sh
# shellcheck shell=dashThe supported values are sh, bash, dash, and ksh. If you target POSIX sh but develop on a system where /bin/sh is actually Bash, the -s sh check is the only thing that will catch a Bashism before it fails on the target box.
Output formats and CI
The default tty output is for humans. For tooling, switch the format with -f:
shellcheck -f gcc script.sh # file:line:col: severity: message (editor-friendly)
shellcheck -f json script.sh # structured, one object per finding
shellcheck -f checkstyle script.sh # Checkstyle XML for CI dashboards
shellcheck -f diff script.sh # a unified diff you can pipe to git applyThat last one is the trick that makes adoption painless: shellcheck -f diff script.sh | git apply auto-applies the fixes ShellCheck is confident about (quoting, backtick-to-$()), so you fix a legacy script in one command and review the diff afterward.
For CI, ShellCheck's exit code is the gate. It exits 0 when clean and 1 when it finds anything at or above the severity threshold (raise the floor with -S error if you only want to fail on errors). A minimal GitHub Actions step:
- name: ShellCheck
run: |
find . -name '*.sh' -print0 | xargs -0 shellcheck -S warningThere's also the widely-used ludeeus/action-shellcheck GitHub Action and an official pre-commit hook in the koalaman/shellcheck-precommit repo if you'd rather not hand-roll the find. The principle is the same either way: a script with an unfixed SC warning never merges.
When ShellCheck is wrong (and when it isn't)
It does not execute the script, so it cannot catch logic errors, wrong flags to external commands, or a regex that matches the wrong thing. It catches shell-level mistakes: quoting, expansion, control flow, exit-code handling, deprecated syntax. Pair it with bash -n script.sh (a pure syntax check) and actually running the thing.
It has blind spots by design. It only analyzes a single file unless you pass -x to follow sourced files, so a function defined in a sourced library is invisible without the source directive. Dynamic constructs (eval, building a command in a variable, computed source paths) defeat static analysis, and ShellCheck will tell you so rather than guess. And on a genuinely intentional pattern it will produce a false positive; that's what the disable directive is for, used narrowly.
The one thing not to do is silence checks in bulk to make the output quiet. A clean ShellCheck run is only worth something if you fixed the findings rather than hid them. Treat each SC code as a question to answer (fix or justify-and-disable), not noise to mute.
FAQ
See also
- Bash functions: arguments, return values, and scope: ShellCheck flags the missing
local, unquoted"$@"forwarding, and return-with-string mistakes covered here. - Bash for loops: the unquoted-glob and word-splitting patterns inside loops are exactly what SC2086 and friends catch.
- Bash if, else, and elif: quoting inside
[ ]tests and thecd ... || exitguard ShellCheck recommends with SC2164. - External: the ShellCheck GitHub repository is the source of truth for flags, directives, and the full check list.
Sources
Authoritative references this article was fact-checked against.





