Bash & the POSIX Shell Module 16 – Quality: ShellCheck, tests, style and speed
ShellCheck: catch bugs before a script runs
Run ShellCheck 0.11.0 on two buggy scripts, read its 14 findings by SC code, and fix warnings like SC2086 and SC2164 instead of muting them.
What you will learn
- Run ShellCheck locally and in a CI job on every script
- Read a report with its file, line, caret, severity and SC code
- Fix warnings such as SC2086 and SC2164 at their cause instead of silencing them
- Check a script as POSIX sh and find the Bash-only features in it
- Use directives and a .shellcheckrc for the findings that are not bugs
Before you start
What you’ll learn · Before you start
What you will learn
- Run ShellCheck locally and in a CI job on every script
- Read a report with its file, line, caret, severity and SC code
- Fix warnings such as SC2086 and SC2164 at their cause instead of silencing them
- Check a script as POSIX sh and find the Bash-only features in it
- Use directives and a .shellcheckrc for the findings that are not bugs
Before you start
On this page
A script that runs today can still carry a bug that only fires next month: an unquoted $dir deletes the wrong
files the first time a path holds a space, and nothing warned you. ShellCheck reads a script without running it and
names that slip by a code, here SC2086, with the file, the line and a caret under the exact characters.
ShellCheck 0.11.0 checks two short scripts here, with twelve slips between them, and each slip is fixed rather than
muted. You will read a report line by line, learn the twelve codes behind those slips and the fix for each, check a
script as POSIX sh to catch Bash-only features, and use directives and a .shellcheckrc for the few findings that
are not bugs.
ShellCheck reads a script without running it, and exits 1 on a finding
ShellCheck: An open-source static-analysis tool (GPL version 3 or later) that reads shell scripts and reports likely bugs and portability problems, each identified by a code such as SC2086 that has its own page on the ShellCheck wiki. It exits 0 when it finds nothing and 1 when it has findings. is a static analysis: Checking a program by reading its source, without running it, to find likely bugs. ShellCheck is a static-analysis tool for shell scripts: it parses the code and reports suspicious patterns, each with a code, and never executes the script. tool for shell scripts:
it parses the code and reports suspicious patterns, and it never runs the script. Install it with a package manager,
such as brew install shellcheck on macOS or sudo apt install shellcheck on Debian and Ubuntu, or unpack a
pre-compiled binary from the project’s release page. A packaged version may be older than 0.11.0, the release every
report on this page comes from, so check with shellcheck --version. Then point it at a file: shellcheck script.sh.
ShellCheck decides which dialect to check against from the script’s shebang: The first line of a script, #! followed by the interpreter to run it, such as #!/usr/bin/env bash. The kernel reads it when the file is run by its path; to Bash it is a comment. More in “Your first script: shebang, chmod and running it”. A file that
starts with #!/usr/bin/env bash is checked as Bash; one with #!/bin/sh as POSIX sh. With no shebang it cannot
tell, and says so. You can force the dialect with -s and one of bash, sh, dash, ksh or busybox. The
tool’s exit status: The number from 0 to 255 every command ends with, kept by the shell in $?. 0 means success; Bash uses 126 for found but not executable and 127 for not found. More in “Your first script: shebang, chmod and running it” is 0 when it finds nothing, 1 when it has findings and 2 when a file
could not be checked at all, such as a name that does not exist, so the same command is useful at the prompt and as a
build step. Run it on a two-line script with one unquoted expansion:
"""Check which ShellCheck runs, then lint a two-line script and its fix.
The session prints the ShellCheck version, runs it on a two-line script with one
unquoted expansion (the `!` marks the command as expected to fail, because ShellCheck exits
1 when it has findings), then runs it on the fixed version, which prints nothing and exits 0.
"""
from session import run, files
files("hello.sh", "hello_fixed.sh")
run("""
shellcheck --version
cat -n hello.sh
!shellcheck hello.sh
cat -n hello_fixed.sh
shellcheck hello_fixed.sh
""") Output
$ shellcheck --version
ShellCheck - shell script analysis tool
version: 0.11.0
license: GNU General Public License, version 3
website: https://www.shellcheck.net
$ cat -n hello.sh
1 dir=$1
2 echo $dir
$ shellcheck hello.sh
In hello.sh line 1:
dir=$1
^-- SC2148 (error): Tips depend on target shell and yours is unknown. Add a shebang or a 'shell' directive.
In hello.sh line 2:
echo $dir
^--^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
echo "$dir"
For more information:
https://www.shellcheck.net/wiki/SC2148 -- Tips depend on target shell and y...
https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...
[exit status 1]
$ cat -n hello_fixed.sh
1 #!/usr/bin/env bash
2 dir=$1
3 echo "$dir"
$ shellcheck hello_fixed.sh
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 run_session.py
Read the finding from left to right. In hello.sh line 2: names the file and line; the next line repeats your code
with a caret span (^--^) under the characters at fault; then comes the code and its severity, SC2086 (info): Double quote to prevent globbing and word splitting, and often a Did you mean: block with the corrected line.
ShellCheck ranks its findings in four severities, from most to least serious: error, warning, info and
style, and -S warning reports only the top two. Here the missing shebang is an error and the unquoted
expansion is info. The fixed script adds a shebang and the quotes, so ShellCheck prints nothing and exits 0.
The code is a search key
Every code has a wiki page at shellcheck.net/wiki/SC2086, with the reason, a good and a bad example, and the
exceptions. When a finding puzzles you, open its page before you change anything.
Every finding names a code, and every code has a fix
Here is a greeting script with six slips, beside the version that fixes them. ShellCheck lists each slip with its line and code; read the script first, then the report under it.
greet.sh
name=$1
today=`date +%F`
echo $name
declare -r me=$(id -un)
read answer
echo "$me said $answer on $today"
grep "$name" /etc/passwd
if [ $? -eq 0 ]; then
echo found
fi greet_fixed.sh
#!/usr/bin/env bash
name=$1
today=$(date +%F)
echo "$name"
me=$(id -un)
readonly me
read -r answer
echo "$me said $answer on $today"
if grep -q "$name" /etc/passwd; then
echo found
fi """Six common slips in one script, then the version that fixes them.
greet.sh carries SC2148, SC2006, SC2086, SC2155, SC2162 and SC2181. The `!` marks the
ShellCheck run as expected to fail (exit 1). greet_fixed.sh fixes each cause and prints
nothing, so ShellCheck exits 0.
"""
from session import run, files
files("greet.sh", "greet_fixed.sh")
run("""
!shellcheck greet.sh
shellcheck greet_fixed.sh
""") Output
$ shellcheck greet.sh
In greet.sh line 1:
name=$1
^-- SC2148 (error): Tips depend on target shell and yours is unknown. Add a shebang or a 'shell' directive.
In greet.sh line 2:
today=`date +%F`
^--------^ SC2006 (style): Use $(...) notation instead of legacy backticks `...`.
Did you mean:
today=$(date +%F)
In greet.sh line 3:
echo $name
^---^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
echo "$name"
In greet.sh line 4:
declare -r me=$(id -un)
^-- SC2155 (warning): Declare and assign separately to avoid masking return values.
In greet.sh line 5:
read answer
^--^ SC2162 (info): read without -r will mangle backslashes.
In greet.sh line 8:
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/SC2148 -- Tips depend on target shell and y...
https://www.shellcheck.net/wiki/SC2155 -- Declare and assign separately to ...
https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...
[exit status 1]
$ shellcheck greet_fixed.sh
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tour_session.py
Six findings, each a real risk. SC2148 is the missing shebang, so ShellCheck had to guess the shell. SC2006 marks
the legacy backticks in today=; date +%F$(...) nests and quotes cleanly, so it is preferred. SC2086
on echo $name is the classic one: an unquoted expansion goes through word splitting: The step in which Bash cuts the unquoted results of parameter expansion, command substitution and arithmetic expansion into separate words at the characters of IFS. More in “Word splitting and IFS: why "$var" needs quotes” and glob pattern: A word containing *, ? or [ that Bash replaces with the sorted names of the files it matches; it stays as written when nothing matches, unless nullglob or failglob is set. More in “Globbing: wildcards, dotfiles, extglob, globstar” expansion, so a name with a space or a * breaks into
several words. quoting: Marking characters so that the shell treats them as ordinary text: a backslash protects one character, single quotes protect everything, double quotes everything except $, backticks and some backslashes. More in “Quoting: single, double, backslash and $'...'” it as "$name" stops that. SC2155 fires on declare -r me=$(id -un): the line takes the status of declare, which succeeds, so a failing id in the command substitution’s
subshell: A copy of the shell that runs commands apart from it, as Bash does for each command of a pipeline. It starts with the shell's variables, and every change it makes to them is lost when it exits. More in “Reading files line by line with read” looks like success. The fix assigns first and marks the name read-only after,
me=$(id -un) then readonly me; with a plain local or declare, declare on one line and assign on the next.
SC2162 says read without -r mangles backslashes in the input. SC2181 flags if [ $? -eq 0 ]: test the command
directly with if grep -q ...; then, because $? is easy to read one command too late.
A second script shows the codes about cd, arrays, loops and traps. This one has a shebang, so ShellCheck checks it
as Bash.
"""Six more slips, about cd, arrays, loops over ls, rm and traps.
backup.sh carries SC2164, SC2068, SC2045, SC2115, SC2064 and SC2128, and two lines also
raise a related code. backup_fixed.sh fixes each cause and prints nothing, so ShellCheck
exits 0.
"""
from session import run, files
files("backup.sh", "backup_fixed.sh")
run("""
!shellcheck backup.sh
shellcheck backup_fixed.sh
""") Output
$ shellcheck backup.sh
In backup.sh line 3:
cd "$dir"
^-------^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
Did you mean:
cd "$dir" || exit
In backup.sh line 5:
tar -czf backup.tgz ${logs[@]}
^--------^ SC2068 (error): Double quote array expansions to avoid re-splitting elements.
In backup.sh line 6:
for f in $(ls *.log); do
^---------^ SC2045 (error): Iterating over ls output is fragile. Use globs.
^-- SC2035 (info): Use ./*glob* or -- *glob* so names with dashes won't become options.
In backup.sh line 9:
rm -rf "$dir"/*
^------^ SC2115 (warning): Use "${var:?}" to ensure this never expands to /* .
In backup.sh line 10:
trap "echo done $dir" EXIT
^--^ SC2064 (warning): Use single quotes, otherwise this expands now rather than when signalled.
In backup.sh line 11:
echo $logs
^---^ SC2128 (warning): Expanding an array without an index only gives the first element.
^---^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
echo "$logs"
For more information:
https://www.shellcheck.net/wiki/SC2045 -- Iterating over ls output is fragi...
https://www.shellcheck.net/wiki/SC2068 -- Double quote array expansions to ...
https://www.shellcheck.net/wiki/SC2064 -- Use single quotes, otherwise this...
[exit status 1]
$ shellcheck backup_fixed.sh
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tour2_session.py
SC2164 warns that a bare cd "$dir" carries on in the old directory if the cd fails, so the next command runs in
the wrong place; the fix adds an exit-on-failure guard, cd "$dir" || exit 1. SC2068 and SC2128 are about arrays: an
unquoted ${logs[@]} re-splits every element, and a bare $logs gives only the first element, never the whole
array. SC2045 flags a loop over ls output, which breaks on names with spaces or newlines; a glob (for f in *.log)
keeps each name whole, though with no match it stays the literal *.log, which the SC2045 page shows how to guard.
SC2115 warns that rm -rf "$dir"/* becomes rm -rf /* when $dir is empty, and suggests "${dir:?}" so the shell
aborts instead. SC2064 notes that a double-quoted trap "... $dir ..." expands $dir when the trap is set, not when
it fires; single quotes delay it. ShellCheck often reports a second, related code on the same line, such as SC2035
next to SC2045, or SC2086 next to SC2128; fix the line and both clear. That makes eight findings for six slips here,
and fourteen for the two scripts.
The codes behind the twelve slips, and the fix for each, fit on one card:
| Code | Severity | What it flags, and the fix |
|---|---|---|
| SC2148 | error | No shebang, so the dialect is unknown — add #!/usr/bin/env bash |
| SC2006 | style | Legacy backticks — write $(...) instead |
| SC2086 | info | Unquoted expansion can split — quote it as "$var" |
| SC2155 | warning | declare x=$(cmd) hides the status — assign apart from declaring |
| SC2162 | info | read mangles backslashes — use read -r |
| SC2181 | style | Testing $? is fragile — test the command directly |
| SC2164 | warning | cd may fail silently — add an exit-on-failure guard |
| SC2068 | error | Unquoted ${a[@]} re-splits — quote it |
| SC2128 | warning | Bare $a gives one element — index or quote "${a[@]}" |
| SC2045 | error | Looping over ls is fragile — loop over a glob |
| SC2115 | warning | "$dir"/* can mean /* — use "${dir:?}"/* |
| SC2064 | warning | Double-quoted trap expands now — single-quote it |
Step through what you do with one finding, from the edit that brought it in to the CI step that catches the next one:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- An edit introduces a slip. You write or change a script, and a small slip comes with it: an unquoted expansion, a cd that might fail, backticks instead of $(...). Nothing looks wrong, and the script may even run on your machine today.
- Run ShellCheck on the file. shellcheck script.sh reads the whole file and runs none of it. It exits 0 when it finds nothing and 1 when it has findings, so the same command works at the prompt and as a CI step.
- Read the finding. Each finding names the file and line, draws a caret under the exact characters, and gives a code such as SC2086 with its severity and a one-line reason. The code is also the name of its wiki page, which says what pattern ShellCheck saw and how to fix it.
- Fix the cause, do not mute it. Change the code so the reason no longer holds: add the quotes, write cd ... || exit, replace backticks with $(...). The warning goes because the risk went, which is the whole point of the tool.
- Run it again. Run shellcheck once more. A clean file prints nothing and exits 0. If a finding remains, the side arrow is the loop: read it and fix its cause too, until the file is clean.
- Gate it in CI. Add shellcheck to the CI job so it runs on every .sh file on every push. The gate fails the build on a non-zero exit, so a new slip is caught before it merges, not months later in production.
Key idea
A warning is a claim about your code, not a nag. SC2086 says this expansion can split; SC2064 says this trap expands too early. Fix the cause and the claim stops being true; mute it and the risk stays.
Checkpoint
Check: Running ShellCheck and reading its report · Twelve SC codes and their fixes
A sh shebang turns Bash-only features into warnings
A script with a #!/bin/sh shebang promises to run under any POSIX shell, not only Bash. On Ubuntu, for one, sh is
dash: The Debian Almquist shell, a small POSIX sh implementation with few extras. On Ubuntu its package provides /usr/bin/sh, so it is the usual strict test of a script that claims to be portable. More in “POSIX sh vs Bash: spotting and fixing bashisms” (its package provides /usr/bin/sh), which lacks most of Bash’s extras; the
POSIX sh versus Bash lesson tries twelve of them there. ShellCheck reads the
shebang and checks the script against POSIX sh, flagging each bashism: A feature of Bash that POSIX does not require, such as arrays, [[ ]], {1..3} or the function keyword. A script that uses one can fail under dash or any other plain POSIX sh. More in “POSIX sh vs Bash: spotting and fixing bashisms”, most of them with an
SC3xxx code. The script below claims sh but is full of Bash:
#!/bin/sh
function greet {
if [[ -n "$1" ]]; then
echo "hi $1"
fi
}
nums=(1 2 3)
echo "${nums[0]}"
first=${1:0:1}
msg=$'tab\tgap'
greet "$msg"
echo "$first" """A #!/bin/sh script checked as POSIX sh: every Bash-only feature is reported.
bashisms.sh has a #!/bin/sh shebang but uses the function keyword, [[ ]], an array, string
indexing and $'...'. The `!` marks the run as expected to fail.
"""
from session import run, files
files("bashisms.sh")
run("""
!shellcheck bashisms.sh
""") Output
$ shellcheck bashisms.sh
In bashisms.sh line 2:
function greet {
^-- SC2113 (warning): 'function' keyword is non-standard. Use 'foo()' instead of 'function foo'.
In bashisms.sh line 3:
if [[ -n "$1" ]]; then
^-----------^ SC3010 (warning): In POSIX sh, [[ ]] is undefined.
In bashisms.sh line 7:
nums=(1 2 3)
^-----^ SC3030 (warning): In POSIX sh, arrays are undefined.
In bashisms.sh line 8:
echo "${nums[0]}"
^--------^ SC3054 (warning): In POSIX sh, array references are undefined.
In bashisms.sh line 9:
first=${1:0:1}
^------^ SC3057 (warning): In POSIX sh, string indexing is undefined.
In bashisms.sh line 10:
msg=$'tab\tgap'
^---------^ SC3003 (warning): In POSIX sh, $'..' is undefined.
For more information:
https://www.shellcheck.net/wiki/SC2113 -- 'function' keyword is non-standar...
https://www.shellcheck.net/wiki/SC3003 -- In POSIX sh, $'..' is undefined.
https://www.shellcheck.net/wiki/SC3010 -- In POSIX sh, [[ ]] is undefined.
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sh_mode_session.py
SC2113 flags the function keyword (write greet() instead); SC3010 the [[ ]] test; SC3030 the array literal;
SC3054 the array reference; SC3057 the string slice ${1:0:1}. SC3003 flags $'tab\tgap', although POSIX.1-2024,
the latest revision of the standard, added $'...' to the shell language: the SC3003 wiki page notes that the rule
last applied under POSIX.1-2017, so for this form ShellCheck 0.11.0 still holds a sh script to the older edition.
The warning still earns its place, because a /bin/sh built to the 2017 edition may lack $'...' and conform all
the same, and Debian’s policy lets a /bin/sh script assume that edition plus a short list of extras that leaves
$'...' out. If a file has no shebang, shellcheck -s sh script.sh forces the same check.
Two honest ways out of an SC3xxx finding
Either keep the feature and promise Bash (change the shebang to #!/usr/bin/env bash, and the SC3xxx codes go),
or keep sh and rewrite the feature the POSIX way (a case instead of [[, positional parameters instead of an
array). Pick by where the script has to run.
Directives and a .shellcheckrc tell ShellCheck what it cannot see
Some findings are not bugs at all: ShellCheck was not allowed to open a file the script reads in, or it cannot know
that an odd-looking line is meant. A lint directive: A specially formatted comment that changes how a linter treats the code near it. In ShellCheck, # shellcheck disable=SC2086 silences one check on the next command, and # shellcheck source=path tells it where a sourced file lives., a comment that ShellCheck reads,
answers both, together with the -x flag. The two scripts below each source a settings file, and the third is the
second with a directive added:
main.sh
#!/usr/bin/env bash
dir=$(dirname "$0")
source "$dir/config.sh"
echo "$SETTING" env.sh
#!/usr/bin/env bash
stage=${1:-dev}
source "settings/$stage.sh"
echo "$SETTING" env_fixed.sh
#!/usr/bin/env bash
stage=${1:-dev}
# shellcheck source=settings/dev.sh
source "settings/$stage.sh"
echo "$SETTING" """Sourced files: SC1091 and -x, then SC1090 and a source= directive.
main.sh sources "$dir/config.sh". A plain run reports SC1091, because ShellCheck opens only the files named on its
command line; with -x it may follow the source, and ShellCheck 0.11.0 drops the leading "$dir/" and reads
./config.sh. env.sh sources "settings/$stage.sh", whose variable sits inside the name, so ShellCheck cannot work it
out (SC1090); env_fixed.sh names the file with "# shellcheck source=settings/dev.sh". The `!` marks a run that is
expected to fail (ShellCheck exits 1 when it has findings).
"""
from session import run, files
files("main.sh", "config.sh", "env.sh", "env_fixed.sh", "config.sh=settings/dev.sh")
run("""
!shellcheck main.sh
shellcheck -x main.sh
!shellcheck -x env.sh
shellcheck -x env_fixed.sh
""") Output
$ shellcheck main.sh
In main.sh line 3:
source "$dir/config.sh"
^--------------^ SC1091 (info): Not following: ./config.sh was not specified as input (see shellcheck -x).
For more information:
https://www.shellcheck.net/wiki/SC1091 -- Not following: ./config.sh was no...
[exit status 1]
$ shellcheck -x main.sh
$ shellcheck -x env.sh
In env.sh line 3:
source "settings/$stage.sh"
^------------------^ SC1090 (warning): ShellCheck can't follow non-constant source. Use a directive to specify location.
For more information:
https://www.shellcheck.net/wiki/SC1090 -- ShellCheck can't follow non-const...
[exit status 1]
$ shellcheck -x env_fixed.sh
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 directives_session.py
The plain run of main.sh reports SC1091, and its message says why: ./config.sh “was not specified as input”. By
default ShellCheck opens only the files named on its command line; -x lets it open any file a script sources, so
shellcheck -x main.sh reads the settings and goes quiet. ShellCheck 0.11.0 even copes with "$dir/config.sh": it
drops one leading variable part and looks for ./config.sh. It gives up when a variable sits inside the name, as in
the "settings/$stage.sh" of env.sh, and reports SC1090 instead. env_fixed.sh adds # shellcheck source=settings/dev.sh above the source line: the directive names the file to read in place of the path built at
run time, and -x still has to allow the read.
A false positive: A warning a tool reports for code that is actually correct in its context. The honest response is a narrow, commented directive that silences that one case, not turning the check off everywhere. is a warning for code that is correct in its context. The script
below names its output files from a printf pattern held in a variable, which SC2059 flags, because a variable in a
format string usually holds data whose % and \ signs printf would obey. Here the pattern is meant to be a format,
the very case the SC2059 wiki page lists as its exception, so a comment and a directive sit on the lines above that
printf:
#!/usr/bin/env bash
# Name the output files from one printf pattern; a job may set its own.
pattern=${FRAME_PATTERN:-frame-%04d.png}
for n in 1 2 3; do
# The pattern is a format on purpose (it holds %04d): SC2059 does not apply.
# shellcheck disable=SC2059
printf -v name "$pattern" "$n"
echo "$name"
done """An honest one-line disable for a printf pattern held in a variable.
frames.sh builds file names from a printf pattern held in a variable, and ShellCheck flags that printf with SC2059.
frames_disable.sh is the same script with a comment and "# shellcheck disable=SC2059" above the printf, because the
pattern is meant to be a format; ShellCheck passes it, and the script prints three names. The `!` marks the run
that is expected to fail.
"""
from session import run, files
files("frames.sh", "frames_disable.sh")
run("""
!shellcheck frames.sh
shellcheck frames_disable.sh
bash frames_disable.sh
""") Output
$ shellcheck frames.sh
In frames.sh line 5:
printf -v name "$pattern" "$n"
^--------^ SC2059 (info): Don't use variables in the printf format string. Use printf '..%s..' "$foo".
For more information:
https://www.shellcheck.net/wiki/SC2059 -- Don't use variables in the printf...
[exit status 1]
$ shellcheck frames_disable.sh
$ bash frames_disable.sh
frame-0001.png
frame-0002.png
frame-0003.png
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 disable_session.py
frames.sh is the same script without those two comment lines, so ShellCheck reports SC2059 and exits 1; with them
it passes, and the script prints its three names. A directive on the line before a command covers that one command,
or a whole loop or function when that is the command, and every other check stays on. One placed before the file’s
first command covers the whole file.
For a whole project, a .shellcheckrc holds the same choices once, so every run and every contributor applies them.
ShellCheck uses the first one it finds in the checked script’s folder or a folder above it, so a file at the root of a
repository covers every script below it:
# .shellcheckrc: ShellCheck uses the first such file it finds in the checked
# script's folder or a folder above it, so one file at the root of a
# repository covers every script below it.
# Open the files that scripts source, the same as passing -x on every run.
external-sources=true
# Switch an optional check on: warn when an all-caps name is read but never
# assigned, which catches a misspelt variable such as $GREETNG.
enable=check-unassigned-uppercase
# Switch one default check off for the whole project, with the reason beside
# it, so the next reader sees a decision rather than an accident.
# SC2001: the team keeps sed for substitutions, so every script reads alike.
disable=SC2001 """A project .shellcheckrc at work, then the same run with --norc.
The sample is copied as project/.shellcheckrc beside copies of main.sh and config.sh. ShellCheck finds it from the
script's folder, and external-sources=true lets it follow the source line without -x; --norc skips the file, and
SC1091 comes back. The `!` marks the run that is expected to fail.
"""
from session import run, files
files("shellcheckrc-sample=project/.shellcheckrc", "main.sh=project/main.sh", "config.sh=project/config.sh")
run("""
cd project
shellcheck main.sh
!shellcheck --norc main.sh
""") Output
$ cd project
$ shellcheck main.sh
$ shellcheck --norc main.sh
In main.sh line 3:
source "$dir/config.sh"
^--------------^ SC1091 (info): Not following: ./config.sh was not specified as input (see shellcheck -x).
For more information:
https://www.shellcheck.net/wiki/SC1091 -- Not following: ./config.sh was no...
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rc_session.py
Each key earns its place. external-sources=true opens sourced files, the same as passing -x, which is why
main.sh is clean beside the file and SC1091 comes back under --norc. enable=check-unassigned-uppercase turns on
an optional check that catches a misspelt all-caps variable. disable=SC2001 turns one default check off for the
whole project, with the reason in the comment above it, so the next reader sees a decision, not an accident. Leave a
shell= key out of the file: there it overrides every shebang, so a #!/bin/sh script would be checked as Bash and
lose its SC3xxx warnings. The habit that ties it together is to run ShellCheck in CI on every .sh file, so a
non-zero exit fails the build and a new slip never reaches the main branch.
Disabling the check instead of fixing the cause
The lazy fix for SC2086 is # shellcheck disable=SC2086 on every line. The warning goes, but the unquoted
expansion is still there, and the day a filename holds a space the script still breaks. Disable a check only when
the code is right and the tool is wrong, and write the reason next to it.
Checkpoint
Check: Checking a script as POSIX sh · Directives and the project .shellcheckrc
Practise
The questions give you a finding to read, a code to match to its fix, a sh shebang to judge, and a directive or a
project file to place. Decide before you check, then open the answer:
Practice that adapts to you
Practise this lesson
10 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): what does SC2086 mean, and how do you clear it?
SC2086 says an expansion is not quoted, so the shell will apply word splitting and glob expansion to its value. If
file holds my report.txt, then echo $file sends two arguments, my and report.txt; if it holds *, the shell
replaces it with the names in the directory. The severity is info, but the bug is real: it surfaces the first time
a value carries a space or a glob character. You clear it by quoting the expansion, echo "$file", which keeps the
value as one word. The same rule covers "$@" and "${arr[@]}". A directive that mutes the code belongs only on a
line you have checked is right as written, with the reason in a comment beside it.
Two deeper problems wait below. Decide your opening move on each before you read the answer: the first is a
ShellCheck job that only fails in CI, the second is keeping a disable comment honest once several people own the
script.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A repository's ShellCheck step is green on every developer's machine but red in CI, and only on one script that sources a shared helper. The finding is SC1091. Explain why the two environments disagree and how you would make the CI run green without weakening the check.
-
Mid to senior
A teammate opens a change that adds
# shellcheck disable=SC2086as the first line of a 200-line script so the build stops failing. Walk through how you decide what is legitimate, and how a team keeps disables honest over months so they do not quietly hide real bugs.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does ShellCheck do with a script, and what do its exit statuses mean?
It reads the script without running it and reports each likely bug with a code, the file, the line and a severity. It exits 0 when clean, 1 when it finds something and 2 when a file cannot be checked, which makes it a CI gate.
-
What does SC2086 mean, and how do you clear it?
An expansion is not quoted, so its value will be split into words and globbed: echo $file sends two arguments when file holds my report.txt. Quote it, "$file", and the finding goes away because the bug does.
-
Why fix the cause of a ShellCheck finding instead of muting it?
Each finding is a claim about the code, such as an expansion that can split or a trap that expands too early. Fixing the cause makes the claim untrue; muting it leaves the risk in place.
-
How does ShellCheck decide which shell language to check?
From the shebang, or from -s, which forces a dialect. Checking as sh flags Bash-only features, mostly with SC3xxx codes.
-
Why does ShellCheck 0.11.0 still warn on $'...' in a sh script?
Its sh checks follow the 2017 edition of POSIX there and report SC3003, although POSIX.1-2024 added the form.
-
How do you tell ShellCheck about a sourced file or a false positive?
-x lets it follow sourced files, and a source= directive names a file it cannot work out. For a false positive, a commented disable directive covers one command, and a .shellcheckrc holds project-wide choices.
Key takeaways
- ShellCheck reads a script without running it and reports each likely bug by a code, with the file, the line, a caret under the characters and a severity (error, warning, info or style); it exits 0 when clean, 1 when it finds something and 2 when a file cannot be checked, which is what makes it a CI gate.
- The shebang picks the dialect, or
-sforces it, and every code has a wiki page with the reason and the fix. - The twelve codes behind the two scripts’ slips are about quoting and expansion (SC2086, SC2068, SC2128), structure (SC2164, SC2045, SC2115, SC2064), declarations (SC2155) and habits (SC2148, SC2006, SC2162, SC2181); fix the cause each names rather than silencing it.
- Checking as POSIX
shflags Bash-only features, mostly with SC3xxx codes, and ShellCheck 0.11.0 still warns on$'...'(SC3003) because itsshchecks follow the 2017 edition there, although POSIX.1-2024 added the form. -xlets ShellCheck follow sourced files and asource=directive names a file it cannot work out; for a false positive, a commenteddisablecovers one command, and a.shellcheckrcholds project-wide choices while every other check stays on.
Terms in this lesson
- false positive
- A warning a tool reports for code that is actually correct in its context. The honest response is a narrow, commented directive that silences that one case, not turning the check off everywhere.
- lint directive
- A specially formatted comment that changes how a linter treats the code near it. In ShellCheck, # shellcheck disable=SC2086 silences one check on the next command, and # shellcheck source=path tells it where a sourced file lives.
- ShellCheck
- An open-source static-analysis tool (GPL version 3 or later) that reads shell scripts and reports likely bugs and portability problems, each identified by a code such as SC2086 that has its own page on the ShellCheck wiki. It exits 0 when it finds nothing and 1 when it has findings.
- static analysis
- Checking a program by reading its source, without running it, to find likely bugs. ShellCheck is a static-analysis tool for shell scripts: it parses the code and reports suspicious patterns, each with a code, and never executes the script.
References
- ShellCheck 0.11.0 manual page (Vidar Holen (ShellCheck))
- ShellCheck README: installing (Vidar Holen (ShellCheck))
- ShellCheck v0.11.0 release notes (Vidar Holen (ShellCheck))
- ShellCheck wiki: checks and directives (Vidar Holen (ShellCheck))
- ShellCheck wiki: Directive (Vidar Holen (ShellCheck))
- ShellCheck wiki: SC2086 (double quote to prevent word splitting) (Vidar Holen (ShellCheck))
- ShellCheck wiki: SC2155 (declare and assign separately) (Vidar Holen (ShellCheck))
- ShellCheck wiki: SC2164 (cd ... || exit in case cd fails) (Vidar Holen (ShellCheck))
- ShellCheck wiki: SC3003 ($'..' in POSIX sh) (Vidar Holen (ShellCheck))
- ShellCheck wiki: SC1091 (not following a sourced file) (Vidar Holen (ShellCheck))
- ShellCheck wiki: SC1090 (non-constant source) (Vidar Holen (ShellCheck))
- ShellCheck wiki: SC2059 (variables in a printf format) (Vidar Holen (ShellCheck))
- POSIX.1-2024: Shell Command Language (The Open Group and IEEE)
- Debian Policy Manual: Scripts (The Debian Project)
- Ubuntu 24.04 package dash: list of files (Canonical (Ubuntu))
Report a problem with this lesson
Kept only in this browser. Your Learn progress