Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 10 minutes
  • Examples run with Bash 5.3.20, dash 0.5.13.5, shellcheck 0.11.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

shellcheck --version, then a buggy two-line script and its fix Python · run_session.py
"""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: six warnings waiting to happen, and the fix

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
ShellCheck on greet.sh, then on the fixed version Python · tour_session.py
"""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.

ShellCheck on backup.sh, then on the fixed version Python · tour2_session.py
"""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:

Interactive · stage by stage From a ShellCheck warning to a clean script Step through what you do with one finding, from the edit that caused it to the CI gate that guards it.
From a ShellCheck warning to a clean script, step 6 of 6. Showing Edit the script, Run shellcheck, Read the finding, Fix the cause, Run it again, Gate it in CI. In focus: Gate it in CI.still warningsEdit the scripta quoting slip creeps inRun shellcheckit reads, runs nothingRead the findingfile, line, caret, codeFix the causequote it, do not mute itRun it againzero findings, exit 0Gate it in CIevery push, every file

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 You add shellcheck script.sh as a CI step, and the script has two SC2086 findings. What happens to the build?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The step exits 1, so the build fails until the warnings are fixed

    Exit 0 means clean and exit 1 means findings of any severity, so a CI step on shellcheck fails on any finding and catches a new slip before it merges.

  2. Question 2 of 2 ShellCheck flags cp $src $dst with SC2086. Which change clears it without changing what the script should do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Quote both expansions, as cp "$src" "$dst"

    SC2086 is about an unquoted expansion that can split or glob; double quotes fix it and keep the expansion, while a disable directive hides the warning and keeps the risk.

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:

bashisms.sh: a POSIX shebang over Bash-only features Shell · bashisms.sh
#!/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"
ShellCheck checks bashisms.sh as POSIX sh Python · sh_mode_session.py
"""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:

Two scripts that source a settings file, and a directive for the second

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"
SC1091 and -x, then SC1090 and a source= directive Python · directives_session.py
"""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:

frames_disable.sh: a printf pattern on purpose, with a commented disable Shell · frames_disable.sh
#!/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
SC2059 on the printf, then the commented disable Python · disable_session.py
"""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: project-wide settings with an explained disable Code · shellcheckrc-sample
# .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
main.sh beside the project file, then with --norc Python · rc_session.py
"""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

  1. Question 1 of 2 A file begins with the sh shebang and contains nums=(1 2 3). What does ShellCheck report, and why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: SC3030, because arrays are undefined in POSIX sh, and on Ubuntu sh is dash

    A sh shebang selects POSIX sh checking, so array literals and references draw SC3xxx codes; promise Bash in the shebang or rewrite without the array.

  2. Question 2 of 2 A loop names files with printf -v name "$pattern" "$n", where pattern is meant to hold a format such as frame-%04d.png, and ShellCheck flags SC2059. Which response keeps the check honest?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A disable=SC2059 directive on the line above the printf, with a comment saying the pattern is a format on purpose

    SC2059 warns that a variable in a format string usually holds data whose % signs printf would obey. When the variable is a format on purpose, the honest answer is the narrowest directive with its reason beside it.

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.

  1. Question 1 of 10 What does ShellCheck do when you run shellcheck script.sh?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: It reads the script and reports likely bugs, without running any of it

    ShellCheck reads without running, so it can flag a bug in a line that would otherwise have deleted files.

  2. Question 2 of 10 A script has no shebang. How does ShellCheck decide which shell to check it against, and what can you do?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: It cannot tell, reports SC2148, and you can force the shell with -s bash or a shell directive

    The shebang normally picks the dialect; without one, ShellCheck reports SC2148, and -s or a shell directive tells it which shell to assume.

  3. Question 3 of 10 A report shows In backup.sh line 3:, then cd "$dir" with a caret under it, then SC2164 (warning). What does this tell you?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: Line 3 of backup.sh has a cd whose failure nothing handles, and ShellCheck rates it a warning

    Each finding reads left to right: file and line, the code with a caret under the characters at fault, then the code, its severity and a one-line reason. SC2164 says the commands after a failed cd would run in the wrong folder.

  4. Question 4 of 10 What does SC2086 tell you about a line such as rm $target?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: The expansion $target is unquoted, so it can word-split and glob

    SC2086 flags an unquoted expansion; quote it as "$target" so a value with a space or a * stays one argument.

  5. Question 5 of 10 ShellCheck flags this line with SC2064. Which rewrite fixes the cause rather than hiding it?

    Read the code, then choose one answer.

    trap "rm -f $tmp" EXIT
    Show the answer: Question 5 of 10

    Answer: Single-quote the action, as trap 'rm -f "$tmp"' EXIT

    SC2064 warns that a double-quoted trap expands now; single quotes make $tmp expand when the trap runs, which is what cleanup needs.

  6. Question 6 of 10 Which shebang makes ShellCheck check a script against POSIX sh?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: A /bin/sh shebang

    ShellCheck reads the shebang: /bin/sh means check as POSIX sh, while a bash shebang allows Bash features.

  7. Question 7 of 10 Why does ShellCheck 0.11.0 still report SC3003 for the $'...' form in sh mode, even though POSIX.1-2024 added it?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: Its sh checks still follow the 2017 edition for this form, and a /bin/sh built to that edition may lack it

    SC3003 in sh mode follows the 2017 edition, which had no $'...'. Keep the warning when the script may meet an older /bin/sh, or promise Bash in the shebang.

  8. Question 8 of 10 What does the comment directive source=lib/default.sh do?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: It names the file ShellCheck should read for the next source line, whose path it cannot work out

    Checking across a source line takes two things: permission to open files (-x or external-sources=true) and a path ShellCheck can work out, which a source= directive supplies when the real one is built at run time.

  9. Question 9 of 10 Halfway down a script, # shellcheck disable=SC2086 sits on the line above a for loop. Which lines does it cover?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: The whole loop, from for to done, because the loop is one command

    A directive applies to the command right after it, which can be a whole loop or function; placed before the file's first command, it applies to the whole file.

  10. Question 10 of 10 A repository's .shellcheckrc contains shell=bash. What happens when ShellCheck checks one of its #!/bin/sh scripts?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: It checks the script as Bash, because the shell key overrides the shebang, so its SC3xxx warnings go

    The manual says the shell key overrides the shell detected from the shebang, and a .shellcheckrc applies to every script below it, so shell=bash there hides the portability warnings of every sh script.

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.

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

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A teammate opens a change that adds # shellcheck disable=SC2086 as 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

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

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

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

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

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

  6. 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 -s forces 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 sh flags Bash-only features, mostly with SC3xxx codes, and ShellCheck 0.11.0 still warns on $'...' (SC3003) because its sh checks follow the 2017 edition there, although POSIX.1-2024 added the form.
  • -x lets ShellCheck follow sourced files and a source= directive names a file it cannot work out; for a false positive, a commented disable covers one command, and a .shellcheckrc holds 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.