skip to content

ShellCheck, shfmt and Style

ShellCheck catches exactly the bug class this whole topic is about — unquoted expansions, ignored cd failures, masked exit statuses — which is why "do you run ShellCheck in CI?" is a routine question. Naming a few SC codes and when a disable comment is legitimate reads as real experience.

part ofBashoverview, primer and where to startread it →
on this pageshow

questions

5

A colleague asks why the CI pipeline runs ShellCheck over every shell script when the scripts already work in production. What class of bugs does ShellCheck actually find, how does it decide which shell dialect to apply, and what does its severity scale mean?

level: middleimportance: must knowfreq 62%

answer

  1. silent-wrong code, not syntax errors
  2. reads source, never runs it
  3. shebang decides the dialect
  4. error, warning, info, style
  5. SC2086, SC2164, SC2155

basics

~20 s

ShellCheck statically analyses shell scripts and flags quoting, expansion and exit-status mistakes that run without any error message yet behave wrongly. It grades findings as error, warning, info or style, and picks the dialect from the script's shebang.

solid answer

~50 s

ShellCheck is a static analyser for shell scripts — effectively the compiler warnings that shell never had. Its value is that shell fails silently: an unquoted expansion that word-splits (`SC2086`), a `cd` whose failure is ignored so the next command runs in the wrong directory (`SC2164`), or `local x=$(cmd)` where `local` masks the command's exit status (`SC2155`) all run cleanly and do the wrong thing. "Works in production" only means those paths have not been hit with a surprising filename or a failing command yet. It picks its dialect from the shebang — `#!/bin/sh` gets POSIX checks and flags bashisms, `#!/usr/bin/env bash` gets bash checks — and with no shebang it reports `SC2148` and asks for one or a `# shellcheck shell=bash` directive. Findings are graded error, warning, info and style; `--severity=warning` gates CI on the serious tiers while you clean up the rest.

go deeper

for a junior

Know that ShellCheck reads a script without running it and reports numbered findings such as SC2086, and be able to say you would run it before committing a script.

for a middle

Explain why shell needs a linter at all: the bugs it finds are valid shell that runs silently and wrongly. Name two or three codes and describe how the shebang selects the dialect.

for a senior

Show how you gate on it: severity thresholds, a pinned tool version so upgrades cannot break unrelated builds, and an honest account of what a clean run does not prove.

for a principal

Own the argument that a linter's value is the bug class it eliminates repo-wide, and be ready to say where you set the severity line, why, and what you do about the findings that fall below it.

## What ShellCheck is ShellCheck is a static analyser for `sh`/`bash` scripts: it parses the script into a syntax tree and pattern-matches against a large catalogue of known-bad constructs, each identified by a stable code of the form `SCnnnn`. "Static" means it never runs your script — it reads the source only. It is the closest thing shell has to a compiler, because shell itself has no compile step: an unparseable line is only discovered when control flow reaches it, and a semantically wrong-but-valid line is never discovered at all. ## The bug class it targets Shell's defining hazard is that broken code runs anyway. The interpreter is happy to hand a command the wrong number of arguments, run the next line after a failure, or expand a variable into a glob. ShellCheck's catalogue is mostly these silent-wrong constructs: ```bash cp $src $dst # SC2086: unquoted; splits on spaces, expands globs cd "$build_dir" # SC2164: if cd fails the rest runs in the wrong directory rm -rf "$dir"/* # SC2115: if dir is empty this becomes /* local out=$(mycmd) # SC2155: local's own status masks mycmd's for f in $(ls *.txt) # SC2012/SC2045: parsing ls breaks on odd filenames echo '$HOME' # SC2016: single quotes do not expand ``` Every one of those is valid shell. None of them produces a diagnostic at runtime. That is why "the script works" is not evidence: it means the inputs so far have not contained a space, the `cd` target has always existed, and the command inside `$( )` has never failed. Beyond the classic quoting and status codes it also reports unused variables (`SC2034`), variables referenced but never assigned (`SC2154`), and constructs that are legal in bash but not in the shell the shebang names. ## Dialect detection ShellCheck must know which shell it is checking, because the same line can be correct in one and broken in another. It reads the shebang: - `#!/bin/sh` — POSIX mode. `[[ ]]`, arrays, `local`, `source`, `<( )` and `${var^^}` are reported as unsupported in this dialect. - `#!/bin/bash` or `#!/usr/bin/env bash` — bash mode; those constructs are accepted. - `#!/bin/dash`, `#!/bin/ksh` — the corresponding dialects. If the file has no shebang — a library meant to be sourced, or a snippet — ShellCheck reports `SC2148` ("Tips depend on target shell and yours is unknown") rather than guessing silently. You fix that either with a directive as the first line of the file: ```bash # shellcheck shell=bash ``` or on the command line with `shellcheck --shell=bash lib/common.sh`. This is why a script whose shebang says `sh` but which is actually always run by `bash` produces a wall of complaints: the shebang, not your intention, is what ShellCheck believes. ## Severity and gating Every finding carries one of four severities, from most to least serious: **error**, **warning**, **info**, **style**. Roughly: error means the code is almost certainly broken or unparseable; warning means it is very likely wrong; info is a correctness-neutral improvement; style is a preference such as using `$( )` instead of backticks. `--severity=LEVEL` (`-S`) sets the *minimum* severity reported, so `-S error` is the loosest gate and `-S style` reports everything. ShellCheck exits non-zero when it reports anything at or above that threshold, which is what makes it usable as a CI step with no wrapper. Individual codes can be dropped with `--exclude=SC2086` (`-e`), and `--format` (`-f`) selects the output shape — `tty` for humans, `gcc`, `checkstyle` or `json` for tooling. Optional checks are off by default and enabled with `--enable`; `--list-optional` prints them. Examples include `require-variable-braces` and `add-default-case`. ## What it cannot do Being static, ShellCheck reasons about the shell text and nothing else. It cannot tell you whether the `awk` program inside your quotes is correct, whether the host in an `ssh` line is reachable, whether a file will exist at runtime, or whether your algorithm is right. It also cannot see into a sourced file unless you point it there. Treat a clean ShellCheck run as "no known shell-level footgun", not as "tested". ## In practice The interview-relevant answer is short: run it on every script, in pre-commit and in CI, pin the version so a tool upgrade cannot turn a green build red on unrelated code, and gate on `warning` or above while the `info`/`style` tail is cleaned up.

  • A library file has no shebang because it is only ever sourced. How do you get ShellCheck to check it properly?
    Put `# shellcheck shell=bash` as the first line of the file, or pass `--shell=bash` on the command line. Without one of those ShellCheck reports SC2148 and cannot apply shell-specific checks, because a construct like `[[ ]]` is fine in bash and broken in dash — it will not guess which one you meant.
  • Your team wants CI to fail on real bugs but not on stylistic nits. How do you configure that?
    Run `shellcheck --severity=warning` so only warning and error findings are reported and can fail the build; info and style findings stop mattering to the exit code. Keep a looser local run at `--severity=style` so people still see the tail, and drop individual codes you have genuinely decided against in a repo-level `.shellcheckrc`.
  • Does a clean ShellCheck run mean the script is correct?
    No. ShellCheck checks shell-level constructs, so it catches quoting, expansion and exit-status hazards. It says nothing about the logic, the embedded `awk` or `sed` programs, whether the paths exist at runtime, or whether the script does the right thing. It removes a bug class; it does not replace tests or review.

ShellCheck is the compiler-warning pass that shell never shipped with: the language will happily run code that means something other than what it looks like, and the linter is the only thing that reads it critically before your users do.

saying these in an interview costs you the question

  • It only finds syntax errors, which bash would catch anyway
  • If the script runs in production it must be fine
  • ShellCheck executes the script to see what it does
  • It assumes bash when there is no shebang
  • A clean ShellCheck run means the script is tested

context

open as a page

ShellCheck flags a line in your bash script that you have decided is genuinely fine. How do you suppress that one finding without turning the check off repository-wide, what scope does the suppression comment have, and when is suppressing one legitimate?

level: middleimportance: should knowfreq 48%

basics

~20 s

Put a # shellcheck disable=SC2086 comment on its own line immediately above the offending command, with a comment saying why. Placed before the first command it silences the whole file instead; repo-wide suppression belongs in a .shellcheckrc.

open as a page

A bash script does `source "$LIB_DIR/common.sh"` and ShellCheck reports SC1090, "can't follow non-constant source"; a second script that sources `lib/common.sh` by a literal path gets SC1091 instead, followed by a pile of "referenced but not assigned" warnings. What is the difference between those two codes, and how do you get ShellCheck to actually read the library?

level: seniorimportance: should knowfreq 34%

basics

~20 s

SC1090 means the sourced path is built from a variable, so ShellCheck cannot know which file to read; SC1091 means the path is literal but the file was not made available. Fix both with a # shellcheck source= directive and run with -x.

open as a page

You inherit a repository with several hundred shell scripts and a currently green CI pipeline, and you want ShellCheck enforced going forward. ShellCheck has no built-in baseline feature. How would you roll it out so the build stays green and the finding count can only go down?

level: principalimportance: should knowfreq 28%

basics

~20 s

Enforce it on a checked-in list of already-clean files rather than on the whole tree, add files to that list as they are fixed, and require every new script to join it. Ratchet the severity threshold down over time.

open as a page

Pull requests that touch the team's bash scripts keep collecting comments about indentation and where `then` should go. What does the shfmt formatter do that ShellCheck does not, and how would you run it so CI fails on unformatted files without rewriting them?

level: juniorimportance: nice to knowfreq 30%

basics

~20 s

shfmt is a formatter: it rewrites shell scripts into a canonical layout, while ShellCheck is a linter that finds bugs and never changes the file. In CI use shfmt -d, which prints a diff and exits non-zero instead of writing.

open as a page