- Section
- Bash
- Updated
- 19 Aug 2026
- Examples
- 16
A shell script that runs is not the same as a shell script that is correct. Bash will happily execute something that breaks the first time it meets a filename with a space in it. Four separate jobs cover most of that gap, and a different tool does each one: checking that the file parses, linting for semantic bugs, formatting for consistency, and testing behavior.
The output below comes from GNU bash 5.3.9, ShellCheck 0.11.0, shfmt 3.13.1 and Bats 1.14.0 on macOS.
Check the Syntax With bash -n
The shell can parse a script without running any of it. bash -n reads the file,
reports the first syntax error it finds, and exits non-zero.
[me@linux ~]$ cat broken.sh
#!/usr/bin/env bash
for f in *.log; do
echo "$f"
[me@linux ~]$ bash -n broken.sh
broken.sh: line 4: syntax error: unexpected end of file from `for' command on line 2
[me@linux ~]$ echo $?
2That exit status makes it usable as a pre-commit or CI gate with no dependencies at all — it is the shell you already have.
It has one limit worth knowing before you rely on it. bash -n does not look
inside sourced files. Nothing is executed in this mode, so the source never
happens and the library is never parsed:
[me@linux ~]$ cat lib.sh
#!/usr/bin/env bash
greet() {
echo "hi"
[me@linux ~]$ cat main.sh
#!/usr/bin/env bash
source ./lib.sh
greet
[me@linux ~]$ bash -n main.sh
[me@linux ~]$ echo $?
0lib.sh has an unterminated function body and bash -n lib.sh catches it, but
bash -n main.sh still passes. Check each file on its own, or use ShellCheck,
which can follow a source when you ask it to.
👉 The same
noexecoption can be set from inside a running script withset -n, where it behaves differently and has its own trap — see 5 Simple Steps On How To Debug a Bash Shell Script.
Lint With ShellCheck
ShellCheck is a static analyzer for
sh and bash by Vidar Holen. It catches the class of bug the shell itself will
never complain about, because the script parses fine and does the wrong thing
anyway. There is a browser version at
shellcheck.net
if you want to try it without installing anything.
[me@linux ~]$ brew install shellcheckPackages are also in Debian, Ubuntu, Fedora and EPEL; the repository lists the current options per platform.
Point it at a script and it reports the line, the column, a code, and a suggested rewrite:
[me@linux ~]$ cat backup.sh
#!/usr/bin/env bash
dest=$1
for f in $(ls /var/log); do
cp /var/log/$f $dest
done
rm -rf $dest/old/*
[me@linux ~]$ shellcheck backup.sh
In backup.sh line 3:
for f in $(ls /var/log); do
^------------^ SC2045 (error): Iterating over ls output is fragile. Use globs.
In backup.sh line 4:
cp /var/log/$f $dest
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
^---^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
cp /var/log/"$f" "$dest"
In backup.sh line 6:
rm -rf $dest/old/*
^---^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
rm -rf "$dest"/old/*Every code has a wiki page explaining the reasoning, which is most of the value
of the tool — SC2045 is not a style nit, it is the reason a log file with a
space in its name silently gets skipped.
Following Sourced Files
Two separate flags are involved, and needing both is easy to miss. -x allows
ShellCheck to follow a source outside the files you listed. -a includes the
warnings it finds in them. Without -a, the sourced file is only read for
definitions:
[me@linux ~]$ shellcheck -x main2.sh
[me@linux ~]$ echo $?
0
[me@linux ~]$ shellcheck -x -a main2.sh
In ./lib2.sh line 3:
cp $1 $2
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
^-- SC2086 (info): Double quote to prevent globbing and word splitting.Filtering and Suppressing
--severity sets the floor, which is how you adopt ShellCheck on an existing
codebase without drowning:
[me@linux ~]$ shellcheck --severity=error backup.sh
In backup.sh line 3:
for f in $(ls /var/log); do
^------------^ SC2045 (error): Iterating over ls output is fragile. Use globs.A single line is silenced with a comment directly above it, and a whole project
with a .shellcheckrc in the directory:
# shellcheck disable=SC2086
cp $1 $2ShellCheck exits 1 when it reports anything and 0 when it is clean, so it
drops into CI without a wrapper.
Format With shfmt
shfmt is part of
mvdan/sh, a shell parser,
formatter and interpreter written in Go by Daniel Martí. It handles POSIX sh,
bash and mksh.
It is a formatter, not a linter. It does not tell you the script is wrong; it rewrites the script to a consistent shape. That makes it complementary to ShellCheck rather than an alternative to it — the two answer different questions and there is no reason to pick one.
[me@linux ~]$ brew install shfmt-d prints a diff and changes nothing, which is the mode to use in CI:
[me@linux ~]$ shfmt -i 2 -d messy.sh
diff messy.sh.orig messy.sh
--- messy.sh.orig
+++ messy.sh
@@ -1,7 +1,6 @@
#!/usr/bin/env bash
-if [ -d "$1" ]
-then
- echo "found"
- else
+if [ -d "$1" ]; then
+ echo "found"
+else
echo "missing"
fiLike ShellCheck it exits 1 when there is a difference. -w writes the result
back instead:
[me@linux ~]$ shfmt -i 2 -w messy.sh
[me@linux ~]$ shfmt -i 2 -d messy.sh
[me@linux ~]$ echo $?
0-i is the indent width. The default is -i 0, which means tabs.
Test With Bats
Bats is a TAP-compliant test runner for
Bash. A .bats file is a Bash script with a @test block per case.
[me@linux ~]$ brew install bats-coreHere is a script and a test file for it. BATS_TEST_DIRNAME is set by Bats to
the directory holding the test, which is how the script under test gets onto the
PATH:
#!/usr/bin/env bash
# greet.sh
name=${1:?usage: greet.sh NAME}
printf 'Hello, %s!\n' "$name"#!/usr/bin/env bats
# greet.bats
setup() {
PATH="$BATS_TEST_DIRNAME:$PATH"
}
@test "greets by name" {
run greet.sh World
[ "$status" -eq 0 ]
[ "$output" = "Hello, World!" ]
}
@test "fails when no name is given" {
run greet.sh
[ "$status" -ne 0 ]
}run executes a command without letting a non-zero exit abort the test, and
populates $status and $output for you to assert against.
[me@linux ~]$ bats greet.bats
greet.bats
✓ greets by name
✓ fails when no name is given
2 tests, 0 failuresA failure names the file, the line, and the assertion that did not hold:
[me@linux ~]$ bats greet.bats
greet.bats
✗ greets by name
(in test file greet.bats, line 10)
`[ "$output" = "Hello, World!" ]' failed
✓ fails when no name is given
2 tests, 1 failureOne thing to expect in CI: Bats checks whether it is writing to a terminal. Piped
or redirected, it emits raw TAP instead of the output above, so the same command
that prints checkmarks locally prints ok 1 greets by name in a build log. Force
either one with -F pretty or -F tap.
Running the Four Together
Each tool exits non-zero on failure, so a check target is four lines and needs no runner:
bash -n script.sh
shellcheck -x -a script.sh
shfmt -i 2 -d script.sh
bats test/Start with bash -n and ShellCheck — they cost nothing and catch the bugs that
actually reach production. Add shfmt when more than one person edits the
scripts, and Bats once a script is load-bearing enough that breaking it would
matter.
