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.

session
[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 $?
2

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

session
[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 $?
0

lib.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 noexec option can be set from inside a running script with set -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.

session
[me@linux ~]$ brew install shellcheck

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

session
[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:

session
[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:

session
[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:

bash
# shellcheck disable=SC2086
cp $1 $2

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

session
[me@linux ~]$ brew install shfmt

-d prints a diff and changes nothing, which is the mode to use in CI:

session
[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"
 fi

Like ShellCheck it exits 1 when there is a difference. -w writes the result back instead:

session
[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.

session
[me@linux ~]$ brew install bats-core

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

bash
#!/usr/bin/env bash
# greet.sh
name=${1:?usage: greet.sh NAME}
printf 'Hello, %s!\n' "$name"
bash
#!/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.

session
[me@linux ~]$ bats greet.bats
greet.bats
 ✓ greets by name
 ✓ fails when no name is given

2 tests, 0 failures

A failure names the file, the line, and the assertion that did not hold:

session
[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 failure

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