| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | ShellCheck 0.10.0shfmt 3.13.1bash 5.2.26-6.el10 |
| Applies to | GNU/Linux and other platforms with ShellCheck and shfmt installed |
| Privilege | Normal user; package install and shfmt install to /usr/local/bin need sudo |
| Scope | ShellCheck static analysis, shfmt formatting, .shellcheckrc and EditorConfig, sourced-file directives, repository scans, and a GitHub Actions quality gate. Does not cover every optional ShellCheck check or a full Bats tutorial. |
| Related guides | Bash strict mode Shell scripting course hub |
In this article we will lint Bash with ShellCheck, format with shfmt, configure project rules, and run the same checks in CI.
Quick Answer
To lint a Bash script, run shellcheck script.sh. To format it, run shfmt -w script.sh; use shfmt -d script.sh to check formatting without modifying the file. ShellCheck flags suspicious shell code; shfmt only changes layout.
ShellCheck vs shfmt
| Tool | Role | Changes files? | Typical CI command |
|---|---|---|---|
| ShellCheck | Static analysis — quoting, control flow, portability | No | shellcheck script.sh |
| shfmt | Formatter — indentation, braces, spacing | Yes with -w; check with -d or -l |
shfmt -d bin/ |
bash -n |
Syntax parse only (no execution) | No | bash -n script.sh |
| Test suite | Runtime behavior | No | Bats or your framework |
ShellCheck is not a formatter. shfmt is not a linter. Neither replaces bash -n or behavioral tests.
Install ShellCheck and shfmt
Create a workspace for the examples in this article:
mkdir -p /tmp/bash-shellcheck-shfmt-lab/{bin,lib}On RHEL 10, install ShellCheck from AppStream:
sudo dnf install -y ShellCheckOn Ubuntu and Debian, use sudo apt install shellcheck.
shfmt is not in the RHEL 10 repositories used here. Download the x86-64 Linux release binary and install it with sudo (normal users cannot write to /usr/local/bin directly). Pick the matching architecture from the shfmt release page on ARM64 or other platforms.
curl -fsSLO https://github.com/mvdan/sh/releases/download/v3.13.1/shfmt_v3.13.1_linux_amd64
sudo install -m 0755 shfmt_v3.13.1_linux_amd64 /usr/local/bin/shfmtRecord the versions your pipeline expects:
printf 'shellcheck %s\nshfmt %s\nbash %s\n' \
"$(shellcheck --version | awk '/version:/{print $2}')" \
"$(shfmt --version)" \
"$(bash --version | head -1 | awk '{print $4}')"shellcheck 0.10.0
shfmt v3.13.1
bash 5.2.26(1)-releaseRHEL 10.2 provided ShellCheck 0.10.0 in this test; upstream releases may contain additional or changed diagnostics, so check shellcheck --version when comparing output.
Record both tool versions in CI logs, and pin shfmt when consistent formatter output matters across runner images.
Lint a Bash Script with ShellCheck
Write a script with an unquoted glob in a for loop:
cat > /tmp/bash-shellcheck-shfmt-lab/bin/unquoted.sh << 'EOF'
#!/usr/bin/env bash
log_dir=/var/log/demo
for file in $log_dir/*.log; do
echo "processing $file"
done
EOFRun ShellCheck on one file:
shellcheck /tmp/bash-shellcheck-shfmt-lab/bin/unquoted.sh
echo "exit: $?"In /tmp/bash-shellcheck-shfmt-lab/bin/unquoted.sh line 3:
for file in $log_dir/*.log; do
^------^ SC2231 (info): Quote expansions in this for loop glob to prevent wordsplitting, e.g. "$dir"/*.txt .
For more information:
https://www.shellcheck.net/wiki/SC2231 -- Quote expansions in this for loop...
exit: 1ShellCheck exit codes matter in CI:
| Exit code | Meaning |
|---|---|
0 |
No issues at the selected severity |
1 |
Issues found |
2 |
File or processing error |
3 |
Invalid directive syntax in the script |
4 |
Invalid ShellCheck option |
A failed pipeline step is not always “a lint finding” — code 2 often means ShellCheck could not read a file.
Fix ShellCheck Diagnostics
Read the wiki link in each message. Fix quoting or control flow; suppress a warning only when you can justify it.
SC2231 — quote the directory, not the glob. Rewrite unquoted.sh with the quoted path:
cat > /tmp/bash-shellcheck-shfmt-lab/bin/unquoted.sh << 'EOF'
#!/usr/bin/env bash
log_dir=/var/log/demo
for file in "$log_dir"/*.log; do
echo "processing $file"
done
EOFConfirm the diagnostic is gone:
shellcheck /tmp/bash-shellcheck-shfmt-lab/bin/unquoted.sh
echo "exit: $?"exit: 0SC2181 — test the command in the if, not $?. Create a script that checks $? indirectly:
cat > /tmp/bash-shellcheck-shfmt-lab/bin/check-status.sh << 'EOF'
#!/usr/bin/env bash
grep -q '^root:' /etc/passwd
if [ $? -eq 0 ]; then
echo "root found"
fi
EOFShellCheck flags the indirect status test:
shellcheck /tmp/bash-shellcheck-shfmt-lab/bin/check-status.shIn /tmp/bash-shellcheck-shfmt-lab/bin/check-status.sh line 3:
if [ $? -eq 0 ]; then
^-- SC2181 (style): Check exit code directly with e.g. 'if mycmd;', not indirectly with $?.
For more information:
https://www.shellcheck.net/wiki/SC2181 -- Check exit code directly with e.g...Rewrite the file with the command inside the if:
cat > /tmp/bash-shellcheck-shfmt-lab/bin/check-status.sh << 'EOF'
#!/usr/bin/env bash
if grep -q '^root:' /etc/passwd; then
echo "root found"
fi
EOFThat matches how Bash error handling teaches status checks: run the command inside the if when success or failure is the branch condition.
Confirm the fix:
shellcheck /tmp/bash-shellcheck-shfmt-lab/bin/check-status.sh
echo "exit: $?"exit: 0Handle Sourced Files and ShellCheck Directives
Fix SC1090 / SC1091 for sourced files
Create a helper and a script that sources it with a runtime path:
cat > /tmp/bash-shellcheck-shfmt-lab/lib/helpers.sh << 'EOF'
#!/usr/bin/env bash
say_hello() {
printf 'hello %s\n' "$1"
}
EOFCreate the caller script with a runtime-determined source path:
cat > /tmp/bash-shellcheck-shfmt-lab/bin/use-source.sh << 'EOF'
#!/usr/bin/env bash
source "$(dirname "$0")/../lib/helpers.sh"
say_hello "world"
EOFWithout source resolution configured, ShellCheck cannot follow the dynamic source path:
shellcheck /tmp/bash-shellcheck-shfmt-lab/bin/use-source.shIn /tmp/bash-shellcheck-shfmt-lab/bin/use-source.sh line 2:
source "$(dirname "$0")/../lib/helpers.sh"
^-- SC1091 (info): Not following: ./../lib/helpers.sh was not specified as input (see shellcheck -x).
For more information:
https://www.shellcheck.net/wiki/SC1091 -- Not following: ./../lib/helpers.s...Option 1 — annotate a dynamic source. SCRIPTDIR is the directory of the script being checked, so the path stays correct regardless of where you run shellcheck:
cat > /tmp/bash-shellcheck-shfmt-lab/bin/use-source.sh << 'EOF'
#!/usr/bin/env bash
# shellcheck source=SCRIPTDIR/../lib/helpers.sh
source "$(dirname "$0")/../lib/helpers.sh"
say_hello "world"
EOFThe directive tells ShellCheck which file the dynamic source line resolves to. SC1091 remains until ShellCheck is allowed to open sourced files outside the CLI inputs (option 2).
Option 2 — enable external-sources=true in .shellcheckrc (next section). That setting lets ShellCheck analyze sourced files that were not passed on the command line.
Targeted disable directives
ShellCheck directives are specially formatted # comments Bash ignores at runtime; baseline # comment syntax and inline-comment rules are in Bash comments. Place directives before the line they affect:
# shellcheck disable=SC2034
UNUSED_VAR=1Do not blanket-disable quoting checks project-wide. Quote the expansion instead of sprinkling disable=SC2086 across the tree.
Configure ShellCheck with .shellcheckrc
Project defaults live in .shellcheckrc at the repository root (or pass --rcfile). Create one in the lab:
cat > /tmp/bash-shellcheck-shfmt-lab/.shellcheckrc << 'EOF'
shell=bash
external-sources=true
EOFRun ShellCheck from the lab root so it picks up that file:
cd /tmp/bash-shellcheck-shfmt-lab
shellcheck bin/use-source.sh
echo "exit: $?"exit: 0shell=bash— Bash dialect for analysis.external-sources=true— allows ShellCheck to open sourced files (off by default).
Use source-path=… when multiple scripts source files by name from common library directories. It tells ShellCheck where to search; external-sources controls whether those files may be opened at all.
Filter severity (optional)
ShellCheck reports error, warning, info, and style. The default includes all of them. The CI example below uses default severity so SC2231, SC2181, and SC2086 from this article would fail the gate.
If an existing codebase has too many low-severity findings to fix at once, introduce ShellCheck gradually:
Use a throwaway script outside the lab tree so the repository scan later stays clean:
cat > /tmp/style-only.sh << 'EOF'
#!/usr/bin/env bash
grep -q '^root:' /etc/passwd
if [ $? -eq 0 ]; then
echo "found"
fi
EOFAt --severity=warning, style-level findings are ignored:
shellcheck --severity=warning /tmp/style-only.sh
echo "exit: $?"exit: 0SC2181 is style, so it is hidden at --severity=warning. Use the same threshold locally if you adopt this filter in CI.
Disable a check project-wide only when the rule is intentionally incompatible with your project's conventions. Prefer targeted directives when only a few lines need an exception.
Format Bash Scripts with shfmt
Create a deliberately messy script:
cat > /tmp/bash-shellcheck-shfmt-lab/bin/messy.sh << 'EOF'
#!/usr/bin/env bash
if [ -f /etc/hosts ];then
echo "hosts file exists"
head -n 1 /etc/hosts
fi
EOFPreview formatted output without writing the file:
shfmt /tmp/bash-shellcheck-shfmt-lab/bin/messy.sh#!/usr/bin/env bash
if [ -f /etc/hosts ]; then
echo "hosts file exists"
head -n 1 /etc/hosts
fiWrite changes in place:
shfmt -w /tmp/bash-shellcheck-shfmt-lab/bin/messy.shshfmt reads the shebang for dialect; pass -ln bash or -ln posix for #!/bin/sh scripts when needed.
Check Formatting Without Changing Files
Use -d in CI to print a diff and exit non-zero when formatting would change:
cat > /tmp/bash-shellcheck-shfmt-lab/bin/messy.sh << 'EOF'
#!/usr/bin/env bash
if [ -f /etc/hosts ];then
echo "hosts file exists"
head -n 1 /etc/hosts
fi
EOFRun the diff check from the lab root so paths in the diff stay short:
cd /tmp/bash-shellcheck-shfmt-lab
shfmt -d bin/messy.sh
echo "exit: $?"diff bin/messy.sh.orig bin/messy.sh
--- bin/messy.sh.orig
+++ bin/messy.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
-if [ -f /etc/hosts ];then
-echo "hosts file exists"
- head -n 1 /etc/hosts
+if [ -f /etc/hosts ]; then
+ echo "hosts file exists"
+ head -n 1 /etc/hosts
fi
exit: 1-l lists files that need formatting (shfmt 3.13.1 exits non-zero when any path is listed):
shfmt -l /tmp/bash-shellcheck-shfmt-lab/bin/messy.sh
echo "exit: $?"/tmp/bash-shellcheck-shfmt-lab/bin/messy.sh
exit: 1Configure shfmt with EditorConfig
shfmt reads EditorConfig properties documented in the shfmt README — including indent_style, indent_size, and the shfmt-specific shell_variant. Create one in the lab:
cat > /tmp/bash-shellcheck-shfmt-lab/.editorconfig << 'EOF'
root = true
[*.sh]
indent_style = space
indent_size = 2
shell_variant = bash
EOFWith .editorconfig in place, shfmt -d checks against two-space indentation:
cd /tmp/bash-shellcheck-shfmt-lab
shfmt -d bin/messy.sh
echo "exit: $?"diff bin/messy.sh.orig bin/messy.sh
--- bin/messy.sh.orig
+++ bin/messy.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
-if [ -f /etc/hosts ];then
-echo "hosts file exists"
- head -n 1 /etc/hosts
+if [ -f /etc/hosts ]; then
+ echo "hosts file exists"
+ head -n 1 /etc/hosts
fi
exit: 1Commit .editorconfig so editors and CI use the same layout without extra CLI flags.
Check All Bash Scripts in a Repository
Create one intentionally unquoted expansion, then run ShellCheck across the repository:
cat > /tmp/bash-shellcheck-shfmt-lab/bin/repo-bad.sh << 'EOF'
#!/usr/bin/env bash
msg="hello world"
echo $msg
EOFLint every *.sh under bin/ and lib/:
find /tmp/bash-shellcheck-shfmt-lab/bin /tmp/bash-shellcheck-shfmt-lab/lib -type f -name '*.sh' -exec shellcheck {} +
echo "exit: $?"In /tmp/bash-shellcheck-shfmt-lab/bin/repo-bad.sh line 3:
echo $msg
^--^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
echo "$msg"
For more information:
https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...
exit: 1find … -exec shellcheck {} + passes multiple files in one ShellCheck invocation and preserves ShellCheck’s own exit codes. For \; versus + and piping inside sh -c, see find -exec examples.
shfmt accepts directories and discovers shell scripts recursively:
cd /tmp/bash-shellcheck-shfmt-lab
shfmt -d bin lib >/dev/null
echo "exit: $?"exit: 1When formatting would change, shfmt -d prints a unified diff per file before exiting non-zero.
Skip generated trees (vendor/, node_modules/, .git/) with find prune expressions or explicit path lists in real repositories.
Run ShellCheck and shfmt in CI
Run cheap checks first: syntax, then ShellCheck, then shfmt. After those pass, run your normal test suite (for example Bats).
Syntax check on a broken script:
cat > /tmp/bash-shellcheck-shfmt-lab/bin/syntax-bad.sh << 'EOF'
#!/usr/bin/env bash
if true; then echo x
EOFCatch the syntax error before lint or format steps run:
bash -n /tmp/bash-shellcheck-shfmt-lab/bin/syntax-bad.sh 2>&1
echo "exit: $?"/tmp/bash-shellcheck-shfmt-lab/bin/syntax-bad.sh: line 2: syntax error: unexpected end of file
exit: 2GitHub Actions does not need special wrappers: a non-zero exit from shellcheck or shfmt -d fails the step. Example workflow:
name: Bash checks
on:
pull_request:
push:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
curl -fsSLO https://github.com/mvdan/sh/releases/download/v3.13.1/shfmt_v3.13.1_linux_amd64
sudo install -m 0755 shfmt_v3.13.1_linux_amd64 /usr/local/bin/shfmt
shellcheck --version
shfmt --version
- name: Syntax, lint, and format check
run: |
find bin lib -type f -name '*.sh' -exec bash -n {} \;
find bin lib -type f -name '*.sh' -exec shellcheck {} +
shfmt -d bin libPin shfmt to the same release in CI and locally. Log shellcheck --version and shfmt --version in the install step so you can compare ShellCheck output and formatter diffs against what you tested locally.
Editor and Pre-Commit Integration
Install ShellCheck and shfmt editor plugins so warnings appear while you type. pre-commit can run shellcheck and shfmt -d on staged *.sh files with the same commands as CI. Format locally with shfmt -w; let CI enforce shfmt -d.
What ShellCheck and shfmt Cannot Detect
- Runtime logic bugs, races, and resource leaks
- Whether sourced files exist on every deployment host
- Correct behavior under real data (use tests after lint and format pass)
Use debug Bash scripts for runtime traces when static analysis is not enough.
Summary
ShellCheck catches many common Bash mistakes before code review; shfmt keeps formatting consistent. Install both, record versions in CI, pin shfmt when formatter output must match, and store defaults in .shellcheckrc and .editorconfig. Fix SC2231 and SC2181 by quoting globs and testing commands directly — suppress a warning only when necessary.
Run the same shellcheck and shfmt -d settings locally and in CI so developers see the same results before pushing. Add bash -n for syntax, then your test suite for behavior. ShellCheck and shfmt are fast gates, not replacements for tests.
References
- ShellCheck wiki
- ShellCheck manual (GitHub)
- shfmt repository and documentation
- shfmt releases and changelog
Frequently Asked Questions
What is the difference between ShellCheck and shfmt?
ShellCheck analyzes scripts for quoting, portability, and suspicious patterns. shfmt reformats whitespace and braces. Neither replaces the other.
How do I ignore one ShellCheck warning?
Add # shellcheck disable=SC#### on the line immediately above the flagged code. Fix the underlying issue when you can; do not disable whole classes without a documented reason.
How do I fix SC1090 or SC1091 for sourced files?
Add # shellcheck source=SCRIPTDIR/../lib/helpers.sh above a dynamic source command so ShellCheck knows which file it refers to. Enable external-sources=true when ShellCheck should analyze sourced files not passed directly on the command line. Use source-path when your project needs additional lookup directories.
How do I check shfmt formatting without modifying files?
Run shfmt -d path or shfmt -d bin/ for a directory. The command exits non-zero when formatting would change.
Does ShellCheck check Bash syntax?
Partially. ShellCheck parses the script for analysis, but bash -n is the quick syntax-only check before execution. Use both in CI.

