Shell Scripting
Write defensively. Shell defaults are hostile — unquoted variables split, unset variables vanish silently, failed commands continue. Every rule here exists to counteract a specific shell default that causes bugs.
References
Extended examples, code patterns, and lookup tables for the rules below.
| Topic | Reference | Contents |
|---|---|---|
| Strict mode, error handling, traps, debugging | strict-mode.md | errexit caveats, pipefail examples, trap patterns, temp file safety, debugging techniques |
| Quoting rules, word splitting, globbing | quoting.md | Three quoting mechanisms, "$@" vs "$*", array expansion, printf vs echo, nested quoting |
| POSIX sh vs bash, portable constructs | portability.md | Feature comparison table, GNU vs BSD tool differences, portable pattern catalog |
| Argument parsing, getopts, validation | arguments.md | getopts template, manual long-option parsing, validation patterns, usage messages, stdin detection |
| Common shell scripting mistakes | pitfalls.md | Iteration pitfalls, variable pitfalls, test pitfalls, pipeline pitfalls, arithmetic traps |
| Pure bash/sh alternatives to external commands | builtins.md | Parameter expansion table, replacing sed/cut/basename/expr, arrays, read patterns, arithmetic |
Script Header
Every bash script starts with:
#!/usr/bin/env bash
set -euo pipefail
- Shebang: Use
#!/usr/bin/env bash— not#!/bin/bash. Theenvlookup is more portable across systems where bash is not at/bin/bash. set -e(errexit): Exit on command failure. Understand the exceptions: commands inif/whileconditions, left side of&&/||, and negated commands (!) do not trigger errexit.set -u(nounset): Error on unset variables. Use${VAR:-default}for optional variables.set -o pipefail: Pipeline returns the rightmost failing command's exit code, not the last command's.- For POSIX sh scripts: Use
#!/bin/sh. Droppipefail(not POSIX). Useset -euwith caution —set -ebehavior varies across sh implementations. - File header comment: After the shebang, add a brief description of what the script does.
#!/usr/bin/env bash
set -euo pipefail
#
# deploy.sh — Build and deploy the application to staging.
Quoting
Quoting is the single most important discipline. Unquoted variables undergo word splitting (breaks on IFS characters) and pathname expansion (glob characters match filenames). Both are silent and devastating.
Core Rules
- Always double-quote variable expansions:
"$var","${var}". - Always double-quote command substitutions:
"$(command)". - Use
"$@"to pass arguments through. Never$*or$@unquoted."$@"preserves each argument as a separate word."$*"joins them. - Quote array expansions:
"${arr[@]}"expands each element as a separate word. Unquoted${arr[@]}undergoes word splitting. - Leave globs unquoted:
for f in *.txt— the glob must expand. But always quote variables inside the loop:"$f". - Leave
[[ ]]right-hand patterns unquoted when doing glob or regex matching. Quote the right side for literal string comparison. - Use single quotes for literal strings that need no expansion:
grep 'pattern' file. - Use
printfinstead ofechofor data output.echointerprets-n,-eas options on some platforms.printf '%s\n' "$var"is always safe.
When Quoting Is Not Needed
- Right side of assignment:
var=$other(no splitting in assignment context) - Inside
(( ))arithmetic:(( x + y )) - Inside
[[ ]]on the left side:[[ $var == pattern ]] - Integer special variables:
$?,$#,$$(guaranteed no spaces) caseword:case $var in ...
Variable Handling
- Naming: lowercase with underscores for local variables (
file_path,line_count). UPPER_CASE for exported/environment variables and constants (PATH,MAX_RETRIES). - Declare constants with
readonly:readonly CONFIG_DIR="/etc/myapp" - Use
localin functions to prevent variable leakage into global scope. Declare and assign on separate lines when capturing command output:
Combinedlocal result result=$(some_command)local result=$(cmd)masks the exit code —localalways returns 0. - Default values: Use
${VAR:-default}to provide defaults without modifying the variable. Use${VAR:=default}to set and use. - Required variables: Use
${VAR:?error message}to abort if unset. - Arrays for lists: Use bash arrays instead of space-delimited strings.
files=("file one.txt" "file two.txt") command "${files[@]}"
Error Handling
- Check every command that can fail. Use
|| exit 1,|| return 1, or explicitifblocks. Especiallycd,mkdir,rm,cp,mv.cd "$dir" || exit 1 - Trap for cleanup. Use
trapon EXIT for reliable cleanup:tmpfile=$(mktemp) || exit 1 trap 'rm -f "$tmpfile"' EXIT - Use
mktempfor temp files. Never hardcoded temp paths. Always clean up via trap. - Error messages to stderr:
die() { printf '%s\n' "$1" >&2; exit "${2:-1}"; } - Exit codes: Return 0 for success, non-zero for failure. Use meaningful codes: 1 for general error, 2 for usage error, 64+ for application-specific errors (following sysexits convention).
- Never use
set -eas a substitute for error handling. It has many edge cases. Use it as a safety net, but still check critical commands explicitly.
Functions
- Declare with
name() { ... }— nofunctionkeyword (it's not POSIX and adds nothing in bash). - Use
localfor all function variables. Bash functions share the caller's scope by default — every undeclared variable is global. - Return values via exit code (0 = success, non-zero = failure) or via stdout. Never rely on global variables for function output.
- Separate
localdeclaration from command substitution:my_func() { local output output=$(some_command) || return 1 } - Put all functions before executable code. Only
setstatements, source commands, and constants should precede function definitions. - Use
mainfor scripts with multiple functions. Callmain "$@"as the last line. This keeps the entry point obvious and lets all variables be local.main() { local arg="$1" # ... } main "$@"
Control Flow
Conditionals
- Use
[[ ]]in bash — it prevents word splitting, supports&&/||inside the test, and enables pattern/regex matching. In POSIX sh, use[ ]with all variables quoted. - Use
(( ))for numeric comparisons:
In POSIX sh:if (( count > 10 )); then ...[ "$count" -gt 10 ]. - Use
==in[[ ]]and=in[ ]for string equality. - Test empty/non-empty explicitly:
[[ -z "$var" ]]and[[ -n "$var" ]]— not[[ "$var" ]]. - Never use
&&/||as if/then/else:# WRONG — cmd3 runs if cmd2 fails, even when cmd1 succeeds cmd1 && cmd2 || cmd3 # RIGHT if cmd1; then cmd2; else cmd3; fi
Loops
- Never parse
lsoutput. Use globs:for f in ./*.txt; do [[ -e "$f" ]] || continue process "$f" done - Use
while readfor line-oriented input:
Thewhile IFS= read -r line; do printf '%s\n' "$line" done < fileIFS=prevents leading/trailing whitespace trimming. The-rprevents backslash interpretation. - Use process substitution to avoid subshell variable loss:
while IFS= read -r line; do (( count++ )) done < <(command) echo "$count" # preserved - Use
find -print0withread -d ''for filenames with special characters:while IFS= read -r -d '' file; do process "$file" done < <(find . -type f -print0)
Case Statements
casefor multi-way branching:case "$1" in start) do_start ;; stop) do_stop ;; restart) do_stop; do_start ;; *) die "Unknown command: $1" ;; esac- Indent patterns by 2 spaces from
case. Put;;on the same line as the action for one-liners, on its own line for multi-line actions.
Input Handling
- Use
getoptsfor short options. It is POSIX, handles combined flags (-vf), and managesOPTARG/OPTINDcorrectly. - Use manual parsing for long options.
while (( $# > 0 )); do caseloop with explicit--handling. - Always handle
--to end option processing — prevents filenames starting with-from being interpreted as options. - Always use
--when passing variables to commands:rm -- "$file" grep -- "$pattern" "$file" - Prefix globs with
./to prevent files named-rffrom becoming options:for f in ./*; do rm -- "$f" done - Provide a
usage()function for any script that takes arguments. Print to stderr and exit with code 64 (EX_USAGE). - Validate arguments early. Check counts, types, file existence before starting work.
- Detect stdin vs terminal:
[[ -t 0 ]]tests whether stdin is a terminal.
Formatting
- Indent with 2 spaces. No tabs (except in
<<-heredocs). - Maximum line length: 80 characters. Use
\continuation or heredocs for long strings. ; thenand; doon the same line asif/for/while:if [[ -f "$file" ]]; then for item in "${arr[@]}"; do while read -r line; do- Split long pipelines one per line with
|on the continuation line:command1 \ | command2 \ | command3 - Use
$(command)not backticks.$()nests cleanly and is readable. Backticks require escaping and don't nest. - Prefer
${var}braces for all variables except positional parameters ($1-$9) and special parameters ($?,$#, etc.).
Portability
- Choose your target. Decide upfront whether you need POSIX sh compatibility or can require bash.
- If targeting bash: use
#!/usr/bin/env bash, use[[ ]], arrays, and process substitution freely. Specify minimum bash version if using 4.0+ features (associative arrays,mapfile, case modification). - If targeting POSIX sh: use
#!/bin/sh, use[ ]with quoted variables, no arrays, no[[ ]], no(( )), nolocal(technically non-POSIX but widely supported), no process substitution. - macOS ships bash 3.2 permanently. If targeting macOS without requiring Homebrew bash, avoid bash 4+ features.
- Avoid GNU-specific tool options when portability matters:
sed -i,grep -P, GNUdateflags. Document the dependency when GNU tools are required. - Use
command -vto check if a program is available — notwhich(which is not a builtin and behaves differently across systems).
ShellCheck Integration
- Run ShellCheck on all scripts. It catches quoting errors, portability issues, and common pitfalls automatically.
- Use a directive comment for intentional violations:
# shellcheck disable=SC2086 word_split_is_intentional $var - Specify shell dialect if the shebang is absent or ambiguous:
# shellcheck shell=bash - Common ShellCheck codes to know:
- SC2086: Double quote to prevent globbing and word splitting
- SC2046: Quote this to prevent word splitting
- SC2034: Variable appears unused (might be exported or sourced)
- SC2155: Declare and assign separately to avoid masking return values
- SC2164: Use
cd ... || exitin casecdfails
Application
When writing shell scripts: Apply all rules silently. Produce clean, defensive code. Use strict mode, quote everything, handle errors, use arrays for lists.
When reviewing shell scripts: Cite the specific rule violated. Show the fix inline. Prioritize: quoting bugs > error handling gaps > style issues.
Integration
the-coderprovides the overall coding workflow (discover, plan, verify)- Language plugins (golang, javascript) handle language-specific tooling
- This skill handles shell-specific correctness and defensive patterns
Quote everything. Handle every error. Trust nothing.
