Lint and Format Bash with ShellCheck and shfmt

Tested on RHEL 10.2 (Coughlan)
Package ShellCheck 0.10.0
shfmt 3.13.1
bash 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
Short on time?Beta Get the gist of this 11-minute read in seconds, then ask follow-up questions

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:

bash
mkdir -p /tmp/bash-shellcheck-shfmt-lab/{bin,lib}

On RHEL 10, install ShellCheck from AppStream:

bash
sudo dnf install -y ShellCheck

On 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.

bash
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

Record the versions your pipeline expects:

bash
printf 'shellcheck %s\nshfmt %s\nbash %s\n' \
  "$(shellcheck --version | awk '/version:/{print $2}')" \
  "$(shfmt --version)" \
  "$(bash --version | head -1 | awk '{print $4}')"
output
shellcheck 0.10.0
shfmt v3.13.1
bash 5.2.26(1)-release

RHEL 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:

bash
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
EOF

Run ShellCheck on one file:

bash
shellcheck /tmp/bash-shellcheck-shfmt-lab/bin/unquoted.sh
echo "exit: $?"
output
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: 1

ShellCheck 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:

bash
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
EOF

Confirm the diagnostic is gone:

bash
shellcheck /tmp/bash-shellcheck-shfmt-lab/bin/unquoted.sh
echo "exit: $?"
output
exit: 0

SC2181 — test the command in the if, not $?. Create a script that checks $? indirectly:

bash
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
EOF

ShellCheck flags the indirect status test:

bash
shellcheck /tmp/bash-shellcheck-shfmt-lab/bin/check-status.sh
output
In /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:

bash
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
EOF

That 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:

bash
shellcheck /tmp/bash-shellcheck-shfmt-lab/bin/check-status.sh
echo "exit: $?"
output
exit: 0

Handle Sourced Files and ShellCheck Directives

Fix SC1090 / SC1091 for sourced files

Create a helper and a script that sources it with a runtime path:

bash
cat > /tmp/bash-shellcheck-shfmt-lab/lib/helpers.sh << 'EOF'
#!/usr/bin/env bash
say_hello() {
  printf 'hello %s\n' "$1"
}
EOF

Create the caller script with a runtime-determined source path:

bash
cat > /tmp/bash-shellcheck-shfmt-lab/bin/use-source.sh << 'EOF'
#!/usr/bin/env bash
source "$(dirname "$0")/../lib/helpers.sh"
say_hello "world"
EOF

Without source resolution configured, ShellCheck cannot follow the dynamic source path:

bash
shellcheck /tmp/bash-shellcheck-shfmt-lab/bin/use-source.sh
output
In /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:

bash
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"
EOF

The 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:

bash
# shellcheck disable=SC2034
UNUSED_VAR=1

Do 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:

bash
cat > /tmp/bash-shellcheck-shfmt-lab/.shellcheckrc << 'EOF'
shell=bash
external-sources=true
EOF

Run ShellCheck from the lab root so it picks up that file:

bash
cd /tmp/bash-shellcheck-shfmt-lab
shellcheck bin/use-source.sh
echo "exit: $?"
output
exit: 0
  • shell=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:

bash
cat > /tmp/style-only.sh << 'EOF'
#!/usr/bin/env bash
grep -q '^root:' /etc/passwd
if [ $? -eq 0 ]; then
  echo "found"
fi
EOF

At --severity=warning, style-level findings are ignored:

bash
shellcheck --severity=warning /tmp/style-only.sh
echo "exit: $?"
output
exit: 0

SC2181 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:

bash
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
EOF

Preview formatted output without writing the file:

bash
shfmt /tmp/bash-shellcheck-shfmt-lab/bin/messy.sh
output
#!/usr/bin/env bash

if [ -f /etc/hosts ]; then
	echo "hosts file exists"
	head -n 1 /etc/hosts
fi

Write changes in place:

bash
shfmt -w /tmp/bash-shellcheck-shfmt-lab/bin/messy.sh

shfmt 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:

bash
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
EOF

Run the diff check from the lab root so paths in the diff stay short:

bash
cd /tmp/bash-shellcheck-shfmt-lab
shfmt -d bin/messy.sh
echo "exit: $?"
output
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):

bash
shfmt -l /tmp/bash-shellcheck-shfmt-lab/bin/messy.sh
echo "exit: $?"
output
/tmp/bash-shellcheck-shfmt-lab/bin/messy.sh
exit: 1

Configure 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:

bash
cat > /tmp/bash-shellcheck-shfmt-lab/.editorconfig << 'EOF'
root = true

[*.sh]
indent_style = space
indent_size = 2
shell_variant = bash
EOF

With .editorconfig in place, shfmt -d checks against two-space indentation:

bash
cd /tmp/bash-shellcheck-shfmt-lab
shfmt -d bin/messy.sh
echo "exit: $?"
output
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

Commit .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:

bash
cat > /tmp/bash-shellcheck-shfmt-lab/bin/repo-bad.sh << 'EOF'
#!/usr/bin/env bash
msg="hello world"
echo $msg
EOF

Lint every *.sh under bin/ and lib/:

bash
find /tmp/bash-shellcheck-shfmt-lab/bin /tmp/bash-shellcheck-shfmt-lab/lib -type f -name '*.sh' -exec shellcheck {} +
echo "exit: $?"
output
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: 1

find … -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:

bash
cd /tmp/bash-shellcheck-shfmt-lab
shfmt -d bin lib >/dev/null
echo "exit: $?"
output
exit: 1

When 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:

bash
cat > /tmp/bash-shellcheck-shfmt-lab/bin/syntax-bad.sh << 'EOF'
#!/usr/bin/env bash
if true; then echo x
EOF

Catch the syntax error before lint or format steps run:

bash
bash -n /tmp/bash-shellcheck-shfmt-lab/bin/syntax-bad.sh 2>&1
echo "exit: $?"
output
/tmp/bash-shellcheck-shfmt-lab/bin/syntax-bad.sh: line 2: syntax error: unexpected end of file
exit: 2

GitHub Actions does not need special wrappers: a non-zero exit from shellcheck or shfmt -d fails the step. Example workflow:

yaml
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 lib

Pin 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


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.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)