How to Keep a Shell Script ShellCheck-Clean from the Start
Choose the target shell, quote data, model arrays and pipelines correctly, and use focused ShellCheck directives only when genuinely needed.
ShellCheck is most useful as design feedback while a script is being written, not as a final ritual that produces a pile of suppressions.
Declare the language truthfully
Start with a shebang matching the features you intend to use:
#!/bin/sh
set -eu
or, when arrays and Bash-specific syntax are required:
#!/usr/bin/env bash
set -euo pipefail
Do not label a script sh and silence warnings about Bash syntax. Portability is a contract. Also understand that set -e has context-dependent behavior and is not exception handling; explicit status checks remain clearer for expected failures.
Quote expansions by default
input=${1:?usage: script INPUT}
cp -- "$input" "$destination/"
Quoted expansions preserve one argument even when values contain spaces or glob characters. Use printf, not echo, for data with unknown leading characters or escape sequences. In Bash, store lists as arrays rather than space-separated strings.
Make data flow visible
Check cd, temporary-file creation, and destructive preconditions explicitly. Avoid parsing ls. Prefer null-delimited interfaces when filenames can be arbitrary:
find "$root" -type f -print0 |
while IFS= read -r -d '' file; do
printf '%s\n' "$file"
done
That example requires a shell whose read supports -d; ShellCheck can help catch the mismatch if the shebang is honest.
Use directives as documented exceptions
Sometimes a sourced file is discovered dynamically or an expansion intentionally performs splitting. Put the narrowest # shellcheck disable=SC... directive beside the line and explain the invariant in a comment. A file-wide disable hides unrelated future bugs.
Run shellcheck in the editor and CI, pin or record the tool version when reproducibility matters, and test the script under every declared shell. Static analysis finds many expansion and portability mistakes, but it cannot prove the external commands, credentials, filesystem, and failure recovery all behave correctly.
Where ShellCheck itself came from
ShellCheck is the work of Vidar Holen (GitHub handle koalaman), with development tracing back to 2012 — copyright notices in the project go back that far, and it was originally offered as a remote web-based checking service before becoming the local command-line tool now bundled in most Linux distributions and installable via Homebrew. It’s implemented in Haskell, a genuinely unusual implementation-language choice for a shell-scripting tool, and released under the GNU General Public License v3. Knowing it’s a single, actively maintained project with a long track record (rather than a loose collection of linting heuristics) is part of why its numbered SC warning codes have become something close to a shared vocabulary for discussing shell-scripting pitfalls across the wider community.
Understanding ShellCheck’s severity levels before deciding what to fix first
ShellCheck classifies findings into four severity levels — error, warning, info, and style — and treating all four identically wastes review time. An error typically flags something that will genuinely break (a syntax problem, a construct that can’t do what it looks like it’s meant to); a warning flags a likely real bug (an unquoted expansion that could split unexpectedly); info and style findings are often legitimate stylistic preferences you may deliberately choose not to follow. Configuring your editor integration or CI check to fail only on error/warning severity, while still surfacing info/style as non-blocking suggestions, is a reasonable middle ground between “ignore ShellCheck entirely” and “block every commit on a style nitpick.”
Silencing a specific check globally versus locally
For a genuinely project-wide, deliberate exception (a house style choice ShellCheck happens to disagree with), a .shellcheckrc file in the project root can disable specific checks repository-wide, rather than sprinkling the same inline directive across every affected file individually. Reserve the file-wide approach specifically for choices that apply consistently across the whole codebase — an exception that’s actually narrow to one specific line, as covered above, still belongs as an inline directive next to that line, not buried in a global configuration file where its original justification is easy to lose track of.
Choosing an output format that fits where ShellCheck actually runs
shellcheck -f gcc myscript.sh # editor-friendly file:line:col messages
shellcheck -f json myscript.sh # for custom tooling or dashboards
shellcheck -f checkstyle myscript.sh # XML, consumable by many CI/IDE integrations
shellcheck -f diff myscript.sh | git apply # apply ShellCheck's own suggested fixes directly
ShellCheck’s default tty output is meant for a human reading a terminal directly, but it also supports gcc-style output that many editors already know how to parse into clickable inline errors, checkstyle XML for CI systems and IDEs built around that long-standing Java-ecosystem format, plain json for anything custom, and a diff format that can be piped straight into git apply or patch to apply ShellCheck’s own auto-generated fix for a finding automatically rather than editing it by hand. Picking the format that matches where the check actually runs — an editor plugin, a GitHub Actions job, a pre-commit hook — is what turns ShellCheck from an occasional manual command into a check that surfaces findings exactly where and when a developer can act on them fastest.
Running ShellCheck automatically in CI without extra setup
GitHub’s own Ubuntu-based Actions runners ship with ShellCheck pre-installed, so a workflow step can invoke shellcheck directly with no separate installation step at all — a meaningfully lower-friction path to “every PR gets checked” than requiring contributors to remember to run it locally first. For a project already using GitHub’s code-scanning features, ShellCheck’s findings can also be converted to SARIF format and uploaded so they appear as inline pull-request annotations alongside other configured security and quality scanners, unifying shell-script findings into the same review surface as everything else the repository already checks.
Related:
- How to Debug Shell Scripts With set -x and ShellCheck
- How to Write Robust, Portable POSIX Shell Scripts
Sources: