Shell Style Guide
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Extends Google Shell Style Guide1.
Quick Reference
| Task | Tool | Command |
|---|---|---|
| Lint | shellcheck2 | shellcheck *.sh |
| Format | shfmt3 | shfmt -w *.sh |
| Test | bats4 | bats tests/ |
| Type check | - | - |
| Semantic | - | - |
| Dead code | - | - |
| Coverage | bashcov5 | bashcov bats tests/ |
| Complexity | - | - |
| Fuzz | - | - |
| Test perf | - | - |
When to Use Shell
Shell MAY be appropriate for:
- Small utilities under 100 lines
- Simple wrapper scripts
- Glue between other programs
- Build/CI scripts
Scripts MUST NOT exceed 100 lines. If your script grows beyond 100 lines or needs complex logic, you MUST rewrite it in Python.
Linting: shellcheck
You MUST use shellcheck2 for linting shell scripts.
Why shellcheck
shellcheck is the world-class shell linter, catching bugs and suggesting best practices. It identifies common pitfalls like unquoted variables, incorrect conditionals, and portability issues before they cause runtime failures.
# Install
# macOS
brew install shellcheck
# Ubuntu/Debian
apt install shellcheck
# Run
shellcheck script.sh
# All files
shellcheck **/*.sh
Configuration
You SHOULD create a .shellcheckrc file:
# Enable all checks
enable=all
# Exclude specific rules if needed
# disable=SC2086
Formatting: shfmt
You MUST use shfmt3 for formatting shell scripts.
Why shfmt
shfmt provides consistent, automated formatting for shell scripts, eliminating style debates and ensuring readability across your codebase. It handles complex cases like heredocs and pipeline formatting correctly.
# Install
go install mvdan.cc/sh/v3/cmd/shfmt@v3.12.0
# Format
shfmt -w script.sh
# Format with specific style
shfmt -i 2 -ci -bn -w script.sh
You MUST use these options:
-i 2: 2-space indent-ci: Indent switch cases-bn: Binary ops on newline
Script Template
All scripts MUST follow this template structure:
#!/bin/bash
#
# Brief description of what this script does.
set -Eeuo pipefail
# Constants
# Assign first, then mark readonly: a combined `readonly x="$(...)"` reports
# readonly's own status and hides a failed substitution from `set -e`.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
# Cleanup
cleanup() {
local exit_code=$?
# Add cleanup logic here
exit "${exit_code}"
}
trap cleanup EXIT ERR
# Functions
log() {
local timestamp
timestamp="$(date +'%Y-%m-%dT%H:%M:%S%z')"
echo "[${timestamp}] $*"
}
err() {
local timestamp
timestamp="$(date +'%Y-%m-%dT%H:%M:%S%z')"
echo "[${timestamp}] ERROR: $*" >&2
}
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS] <argument>
Description of what this script does.
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
Examples:
$(basename "$0") input.txt
$(basename "$0") -v input.txt
EOF
}
main() {
local verbose=false
while [[ $# -gt 0 ]]; do
case "$1" in
-h | --help)
usage
exit 0
;;
-v | --verbose)
verbose=true
shift
;;
*)
break
;;
esac
done
if [[ $# -lt 1 ]]; then
err "Missing required argument"
usage
exit 1
fi
local input="$1"
if [[ "${verbose}" == true ]]; then
log "Running from: ${SCRIPT_DIR}"
log "Processing: ${input}"
fi
# Main logic here
}
main "$@"
The template is executable documentation: it MUST pass shellcheck under
the enable=all configuration above and shfmt -i 2 -ci -bn -d unmodified.
Copy it verbatim and delete what you do not need.
Why
readonly NAME="$(command)", local name="$(command)", and
declare -r NAME="$(command)" return the status of readonly, local, or
declare, never the status of command. A failed substitution is therefore
invisible to set -e
(SC2155). Under Bash 5.3.15,
set -e; readonly X="$(false)" continues and exits 0, while
set -e; X="$(false)"; readonly X aborts with status 1. Splitting the
assignment is what makes a failed cd in SCRIPT_DIR stop the script.
log and err assign timestamp before printing for the same reason:
embedding $(date ...) directly in echo discards date’s exit status
(SC2312). Braces around every named
variable satisfy SC2250, which
enable=all turns on.
Key Conventions
Shebang and Options
All scripts MUST include:
#!/bin/bash
set -Eeuo pipefail
set -E: Inherit ERR trap in functions (REQUIRED)set -e: Exit on error (REQUIRED)set -u: Error on undefined variables (REQUIRED)set -o pipefail: Pipelines fail on first error (REQUIRED)
Variables
You MUST follow these variable conventions:
# Use braces and quotes (REQUIRED)
echo "${variable}"
# Local variables in functions (REQUIRED)
my_function() {
local my_var="value"
}
# Constants are uppercase (REQUIRED)
readonly MAX_RETRIES=3
declare -r CONFIG_FILE="/etc/myapp.conf"
Tests
You MUST use [[ ]] for tests, not [ ]:
# Use [[ ]] not [ ] (REQUIRED)
if [[ "${var}" == "value" ]]; then
echo "match"
fi
# String tests
if [[ -z "${var}" ]]; then # Empty
if [[ -n "${var}" ]]; then # Not empty
# File tests
if [[ -f "${file}" ]]; then # File exists
if [[ -d "${dir}" ]]; then # Directory exists
Command Substitution
You MUST use $() for command substitution, not backticks:
# Use $() not backticks (REQUIRED)
result="$(command)"
nested="$(command "$(other_command)")"
Arrays
# Declare arrays
declare -a files
files=("one.txt" "two.txt" "three.txt")
# Append
files+=("four.txt")
# Iterate
for file in "${files[@]}"; do
echo "${file}"
done
# Length
echo "${#files[@]}"
Error Handling
# Check return values
if ! command; then
err "Command failed"
exit 1
fi
# Or with ||
command || { err "Failed"; exit 1; }
Defensive Patterns
Trap-Based Cleanup
Scripts SHOULD implement cleanup handlers for reliable resource management:
# Holds only this script's own temporary directory; empty until mktemp succeeds.
tmp_dir=''
cleanup() {
local exit_code=$?
if [[ -n "${tmp_dir}" && -d "${tmp_dir}" ]]; then
rm -rf -- "${tmp_dir}"
fi
exit "${exit_code}"
}
trap cleanup EXIT ERR
A cleanup handler MUST delete only paths the script itself created, and
MUST NOT recursively delete TMPDIR or any other inherited path:
# Don't: TMPDIR is the caller's temporary root, which the script did not create
rm -rf -- "${TMPDIR:-}"
# Do: delete only the allocation this script owns
if [[ -n "${tmp_dir}" && -d "${tmp_dir}" ]]; then
rm -rf -- "${tmp_dir}"
fi
Why
The -E flag in set -Eeuo pipefail ensures ERR traps propagate into
functions. Without cleanup traps, scripts may leak temporary files, leave
processes running, or fail to release locks when errors occur.
TMPDIR is an inherited environment variable naming a shared temporary
root. rm -rf -- "${TMPDIR:-}" therefore destroys whatever the caller pointed
at, including other processes’ work, even when the script allocated nothing:
running that handler with TMPDIR set to a populated directory removed the
whole tree. Guarding on a private tmp_dir that is empty until mktemp -d
succeeds means the failure path deletes nothing.
Safe Temporary Files
You SHOULD use mktemp with the cleanup trap above for temporary files.
Register the trap first, then allocate, so an allocation failure leaves
tmp_dir empty and cleanup inert:
tmp_dir="$(mktemp -d)" || {
err "Failed to create temp dir"
exit 1
}
Why a private variable
Assigning the allocation to TMPDIR instead of a private variable overwrites
the caller’s setting, redirects every later mktemp in the process, and makes
the cleanup target ambient state rather than an owned one.
Dependency Checking
Scripts SHOULD verify required commands exist before use:
check_deps() {
local -a missing=()
for cmd in "$@"; do
command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
done
if [[ ${#missing[@]} -gt 0 ]]; then
err "Missing dependencies: ${missing[*]}"
exit 1
fi
}
check_deps jq curl git
Note: Use command -v instead of which—it’s a shell builtin and more portable.
Testing: bats
You SHOULD use bats4 (Bash Automated Testing System) for testing shell scripts.
Why bats
bats provides a simple, TAP-compliant testing framework purpose-built for shell scripts. It offers clean syntax, setup/teardown hooks, and integrates with CI systems out of the box.
# Install
# macOS
brew install bats-core
# npm
npm install --global bats@1.13.0
# Verify
bats --version
Project Structure
project/
├── bin/
│ └── script.sh # executable; ends with main "$@", never sourced
├── lib/
│ └── helpers.sh # sourceable functions only, no main call
├── tests/
│ ├── script.bats
│ ├── test_helper.sh
│ └── fixtures/
└── README.md
Executables and libraries MUST be separate files. An executable that ends
with main "$@" MUST NOT be sourced by a test; only files that define
functions and call nothing MAY be sourced.
# lib/helpers.sh
slugify() {
local text="$1"
printf '%s' "${text,,}" | tr -cs 'a-z0-9' '-'
}
Why
Sourcing bin/script.sh runs main with the test runner’s arguments. Under
the mandatory set -Eeuo pipefail, the missing-argument branch calls
exit 1 and terminates the enclosing Bats process before any @test runs:
sourcing the template from a wrapper printed its usage text and exited 1
without reaching the following statement.
Basic Test File
#!/usr/bin/env bats
bats_require_minimum_version 1.5.0
setup() {
# Runs before each test. Resolve the executable; never source it.
script="${BATS_TEST_DIRNAME}/../bin/script.sh"
# Libraries define functions only, so sourcing them is safe.
source "${BATS_TEST_DIRNAME}/../lib/helpers.sh"
}
@test "script prints usage with --help" {
run -0 "${script}" --help
[[ "${output}" == *"Usage:"* ]]
}
@test "script fails with missing argument" {
run ! "${script}"
[[ "${output}" == *"Missing"* ]]
}
@test "slugify lowercases and hyphenates" {
run -0 slugify 'Release Notes v2'
[ "${output}" = "release-notes-v2" ]
}
Why resolved paths
bin/ is not on PATH inside a test, so bare run script.sh exits 127 with
Command not found, and Bats then reports that it executed 0 of the expected
tests. Resolving the path from BATS_TEST_DIRNAME makes the invocation
independent of the working directory. run -0 and run ! assert the exit
status inside run itself, so an unexpected 127 fails the test instead of
silently satisfying [ "$status" -ne 0 ]; both forms require the
bats_require_minimum_version 1.5.0 declaration.
There is no teardown deleting BATS_TEST_TMPDIR: Bats creates that directory
per test and removes it after the run. Recording the path from inside a test
and checking it afterwards showed the directory already gone.
Assertions
# Exit code
[ "$status" -eq 0 ] # Success
[ "$status" -ne 0 ] # Failure
[ "$status" -eq 1 ] # Specific code
# Output matching
[ "$output" = "exact match" ]
[[ "$output" == *"substring"* ]]
[[ "$output" =~ ^pattern$ ]]
# Line-by-line output
[ "${lines[0]}" = "first line" ]
[ "${#lines[@]}" -eq 3 ] # Line count
# File assertions
[ -f "$file" ] # Exists
[ -s "$file" ] # Non-empty
diff "$file" expected.txt # Content match
Mocking Commands
@test "handles curl failure" {
# Mock curl to fail
curl() { return 1; }
export -f curl
run fetch_data
[ "$status" -ne 0 ]
[[ "$output" == *"fetch failed"* ]]
}
Skipping Tests
@test "requires jq" {
command -v jq >/dev/null 2>&1 || skip "jq not installed"
run process_json
[ "$status" -eq 0 ]
}
CI Integration
Workflow examples in this guide are complete files. Each MUST declare an
on trigger, grant only permissions: contents: read, set a finite
timeout-minutes, and cancel superseded runs through concurrency:
# .github/workflows/test.yml
name: Shell Tests
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install bats
run: npm install --global bats@1.13.0
- name: Run tests
run: bats tests/
Why complete workflows
A file under .github/workflows/ without on is not a workflow: GitHub’s
workflow syntax defines on as the event configuration6, and actionlint
1.7.12 rejects a trigger-less body with "on" section is missing in workflow.
GitHub’s secure-use reference asks for the GITHUB_TOKEN default to be read
access to repository contents only7; linting and testing need nothing more.
timeout-minutes bounds a hung step, which would otherwise hold a runner until
the platform’s own job execution limit expires, and cancel-in-progress stops
superseded pushes queuing behind each other.
Pipelines
# Split long pipelines
command1 \
| command2 \
| command3
Coverage: bashcov
You MAY use bashcov5 for code coverage of shell scripts.
# Install
gem install bashcov
# Run with bats
bashcov bats tests/
# With simplecov output
bashcov --root . bats tests/
Pre-commit Configuration
You SHOULD configure pre-commit hooks8:
repos:
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1
hooks:
- id: shellcheck
- repo: https://github.com/scop/pre-commit-shfmt
rev: v3.12.0-2
hooks:
- id: shfmt
args: [-i, "2", -ci, -bn]
CI Pipeline
You SHOULD integrate shell linting into your CI pipeline9:
# .github/workflows/lint.yml
name: Shell Lint
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: shellcheck
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # 2.0.0
with:
scandir: './scripts'
- name: Install shfmt
env:
SHFMT_VERSION: 3.14.1
SHFMT_SHA256: 76e77641faa025814b77f153b29796b8e6fa2fca03e0c76a691608b86c7ea7bf
run: |
base=https://github.com/mvdan/sh/releases/download
curl -fsSL -o /tmp/shfmt \
"${base}/v${SHFMT_VERSION}/shfmt_v${SHFMT_VERSION}_linux_amd64"
echo "${SHFMT_SHA256} /tmp/shfmt" | sha256sum --check --strict
mkdir -p "${HOME}/.local/bin"
install -m 0755 /tmp/shfmt "${HOME}/.local/bin/shfmt"
echo "${HOME}/.local/bin" >> "${GITHUB_PATH}"
- name: shfmt
run: shfmt -i 2 -ci -bn -d .
You MUST NOT use uses: mvdan/sh@v0.7. The mvdan/sh repository publishes
no action at any tag, and v0.7 does not exist, so the step fails to resolve
before it runs.
Why
shfmt ships as a single static binary per release3. Downloading the pinned
v3.14.1 asset and checking its SHA-256 gives an immutable dependency without
trusting a third-party action, and the checksum step fails loudly if the asset
ever changes. -d reports differences and exits non-zero rather than
rewriting files in CI, and the flags match those the guide mandates for local
formatting.
See Also
- CI Guide - Comprehensive CI/CD pipeline configuration
- Google Shell Style Guide - Extended style guide
- Testing Guide - Testing practices and patterns
References
Footnotes
-
Google Shell Style Guide - Google’s comprehensive shell scripting style guide ↩
-
ShellCheck - A shell script static analysis tool ↩ ↩2
-
shfmt - A shell parser, formatter, and interpreter with bash support ↩ ↩2 ↩3
-
Workflow syntax -
on,permissions,concurrency, timeouts ↩ -
Secure use - Least-privilege
GITHUB_TOKENguidance ↩ -
pre-commit - A framework for managing and maintaining multi-language pre-commit hooks ↩
-
GitHub Actions - GitHub’s CI/CD platform documentation ↩