This document defines the style for writing portable
POSIX shell scripts. It
aims to produce scripts that work across different shells
(bash,
dash,
ash) and operating systems
while being readable and maintainable.
The terms MUST, SHOULD, and other key words are used as defined in RFC 2119 and RFC 8174.
- When to use shell
- Linting and automatic formatting
- File format and permission
- Shebang
- Indentation
- Line length
- Quoting
- Variables
- Functions
- Command substitution
- Conditionals and tests
- Output and printing
- Error handling
- Comments
- Shell in documentation
- Portability
shell-boilerplate.shshell-snippets.sh- Author information
Shell scripting is a portable tool when following some rules, but it is not suitable for every task and there are many pitfalls. Use shell scripts where they make sense and delegate to other languages where they struggle.
Shell is RECOMMENDED for:
- Short, simple scripts with straightforward logic:
- System administration tasks like log rotation, backups, and service management.
- Build scripts, CI/CD pipelines, and deployment automation.
- Containerfiles
- Task that primarily involves invoking other command-line tools:
- Top-level wrappers that set up the environment
- Glue scripts that coordinate the execution of multiple tools.
Shell is NOT RECOMMENDED for:
- Data processing beyond simple text manipulation.
- Complex logic with many conditionals and nested structures.
- Tasks requiring robust error handling and recovery.
- Generating or parsing structured data formats (JSON, XML, YAML).
- Cross-platform applications where Python or similar languages would be more portable.
You SHOULD:
- Keep shell scripts under 1000 lines including comments. If a script grows larger, consider refactoring or using a different language.
- Prefer calling well-tested command-line tools over reimplementing their functionality in shell.
- Use a layered architecture: shell for the outer wrapper, Python (or another language) for complex logic.
Reasoning:
- Shell is good at orchestrating other programs but lacks features for complex programming (proper data structures, exception handling, testing frameworks).
- Shell's quoting rules, word splitting, and glob expansion create numerous pitfalls that grow with script complexity.
- Debugging and testing shell scripts is significantly harder than in languages with proper tooling.
- The same task that takes 20 lines of shell might take 10 lines of Python with better error handling and readability. But Python surely is not as portable because of package dependencies etc., especially if it comes to OCI containers.
You MUST:
-
On POSIX scripts (shebang
#!/usr/bin/env sh):-
Run
shfmt:shfmt --language-dialect posix --indent 2 --case-indent --binary-next-line --simplify --diff script.sh shfmt --language-dialect posix --indent 2 --case-indent --binary-next-line --simplify --write script.sh
-
Run
shellcheck:shellcheck --shell=sh --severity=style --exclude=SC2292 --exclude=SC3040 --exclude=SC3043 --enable=all script.sh
-
Run
checkbashisms:checkbashisms script.sh
-
-
On Bash scripts (shebang
#!/usr/bin/env bash):-
Run
shfmt:shfmt --language-dialect bash --indent 2 --case-indent --binary-next-line --simplify --diff script.sh shfmt --language-dialect bash --indent 2 --case-indent --binary-next-line --simplify --write script.sh
-
Run
shellcheck:shellcheck --shell=bash --severity=style --exclude=SC2292 --exclude=SC3040 --exclude=SC3043 --enable=all script.sh
-
-
Fix all errors and warnings. If a warning must be silenced, add a comment explaining why:
# shellcheck disable=SC2034 # Variable is exported for use by sourcing script unused_but_exported_var='value'
Reasoning:
shfmteliminates style debates by providing a single, deterministic format.- Automated tools catch errors that humans miss and enforce consistency without
manual effort:
shellcheckidentifies bugs, security issues, and portability problems. See the ShellCheck Wiki for a complete list of checks.checkbashismscatches many (not all) bash-specific constructs that break POSIX compatibility (note: it has some false positives and does not catch all issues).
You MUST:
- Use UTF-8 encoding. Do not use a Byte Order Mark (BOM).
- Use Unix line endings (LF,
\n). Do not use Windows (CRLF) or old Mac (CR) line endings. - End files with a single trailing newline.
- Trim trailing whitespace from lines.
- Never use SUID or SGID permissions on
shell scripts. Use
sudoto provide elevated access if needed.
Reasoning:
- Unix shells expect LF line endings. CRLF can cause subtle bugs or syntax errors.
- BOMs interfere with the shebang (
#!) mechanism and can cause scripts to fail silently. - Trailing newlines are a POSIX requirement and prevent issues when concatenating files or reading the last line.
- UTF-8 is the universal standard for text encoding and is expected by modern systems.
- SUID/SGID on shell scripts is a security vulnerability: most systems ignore
these bits on scripts, and where they work, they create privilege escalation
risks through environment manipulation
(
IFS,PATH,LD_PRELOAD).
File extensions and permissions:
| Type | Extension | Executable | Notes |
|---|---|---|---|
| Executable script | .sh or none |
Yes (chmod +x) |
No extension if installed in PATH |
| Library/sourced | .sh |
No (chmod -x) |
Only sourced, never executed directly |
Library files should not have execute permission to prevent accidental direct execution and to clearly indicate their purpose.
You MUST:
- Start every script with a shebang on the first line.
- Use
#!/usr/bin/env shas the default shebang for POSIX-compliant scripts. - Use
#!/usr/bin/env bashonly when Bash-specific features are required.
You SHOULD:
- Document why Bash is required if using the Bash shebang.
Good examples:
#!/usr/bin/env sh
#
# This is a very cool script
#!/usr/bin/env bash
# Bash required for: associative arrays
#
# This is a very cool scriptBad examples:
#!/bin/sh
# Not portable: /bin/sh location varies across systems
#!/bin/bash
# Not portable: bash location varies across systems
#! /usr/bin/env sh
# Space after #! can cause issues on some systemsReasoning:
- Using
envensures the script finds the interpreter in the user'sPATH, making it portable across systems where shell locations differ (e.g.,/bin/shvs/usr/bin/sh). - The
shinterpreter provides maximum portability when writing POSIX-compliant scripts. - Explicitly requiring Bash should be a conscious decision, documented for future maintainers.
- Tangential note (not a shebang issue): some environments bypass or ignore
env(1)entirely (e.g., Ansible'sshellmodule withargs: executable:). In such cases, fall back to the most widespread direct paths:/bin/sh,/bin/bash.
You MUST:
- Use 2 (two) spaces for each indentation level. Do not use tabs.
- Exception: using tabs is for the body of
<<-tab-indented here-document. - Exception: Be consistent and use 4 spaces if an established project of us is using it. Open an dedicated issue for reformatting if so.
- Exception: using tabs is for the body of
- Use consistent indentation throughout the script.
- Indent the bodies of
if,for,while,case, and function blocks.
You SHOULD:
- Place
then,doon the same line asif,for,while(separated by;or after a newline for multi-line conditions). - Indent
casepattern bodies.
Good examples:
# 2 spaces per indentation level
if [ -f "${config_file}" ]; then
process_config "${config_file}"
fi
# Multi-line conditions (operator at start of continuation line)
if [ -n "${foo}" ] \
|| [ -n "${bar}" ]; then
printf '%s\n' 'At least one is set'
fi
# Case statement with indented bodies
case "${option}" in
start)
start_service
;;
stop)
stop_service
;;
*)
printf '%s\n' 'Unknown option' >&2
exit 1
;;
esac
# Nested blocks
for file in '/file1' '/foo/file2' './file-3'; do
if [ -r "${file}" ]; then
process_file "${file}"
fi
doneBad examples:
# Using tabs
if [ -f "${config_file}" ]; then
» process_config "${config_file}" # tab character
fi
# Inconsistent indentation
if [ -f "${config_file}" ]
then
··process_config "${config_file}" # 2 spaces
····validate_config # 4 spaces
fi
# No indentation
if [ -f "${config_file}" ]
then
process_config "${config_file}"
fiReasoning:
- Spaces ensure consistent display across all editors and terminals regardless of tab width settings.
- Pasting functions with tabs into an interactive shell can cause all kinds of weird errors.
- Using two spaces aligns us with the broader shell and container ecosystem (cf. Google, Kubernetes, Bash Hacker's Wiki) and provides a good enough visual hierarchy as most shell scripts should not have deeply nesting logic.
You MUST:
- Keep lines under 120 characters whenever technically possible.
- Break long pipelines before the pipe character (
|), with the continuation indented. - Break long logical expressions before the operator (
&&,||), with the continuation indented.
You SHOULD:
- Keep lines under 80 characters when possible.
- Use backslash (
\) continuation for long commands that are not pipelines.
Good examples:
# Breaking a long command with backslash
curl --silent --location --retry 3 \
--output "${download_dir}/${filename}" \
"${download_url}"
# Breaking a pipeline: pipe at start of continuation line
find "${source_dir}" -type f -name '*.txt' \
| grep -v 'backup' \
| sort \
| head -n 10
# Complex pipeline with clear structure
cat "${input_file}" \
| grep -E '^[0-9]+' \
| awk '{ sum += $1 } END { print sum }' \
| tee "${output_file}"
# Breaking logical expressions: operator at start of continuation line
if [ -f "${config_file}" ] \
&& [ -r "${config_file}" ] \
&& [ -s "${config_file}" ]; then
process_config "${config_file}"
fi
# Long string without unwanted whitespace: printf joins the parts
printf '%s%s\n' 'This is a single parameter ' \
'because printf joins both arguments without a separator'Bad examples:
# Excessively long line
curl --silent --location --retry 3 --output "${download_dir}/${filename}" --connect-timeout 30 --max-time 300 "${download_url}"
# Operator at end of line
find "${source_dir}" -type f -name '*.txt' |
grep -v 'backup' |
sort
# Backslash with space after it (causes syntax error)
curl --silent \·
--locationReasoning:
- Shorter lines are easier to read, especially in side-by-side diffs or on smaller screens.
- Placing operators at the start of continuation lines improves readability by making the logical structure immediately visible when scanning the left edge of the code.
You MUST:
- Quote all variable expansions:
"${variable}", not$variable. - Quote all command arguments that could contain spaces or special characters.
- Use double quotes when variable expansion is needed.
- Use single quotes when literal strings are needed (no expansion).
- Correctly escape identical quotes within quotes (use
'\''and"\"", not\'or\") - Quote the delimiter of a here-document (
<<'EOF') when its body must not expand variables, command substitutions or backslashes. Leave it unquoted only when the body intentionally expands${variables}.
You SHOULD:
- Prefer single quotes for static strings to prevent accidental expansion.
- Use double quotes for strings containing variables or command substitutions.
- Mix quotes when it prevents complicated escaping.
- Always quote variables in test expressions:
[ -n "${var}" ]. - Quote all command arguments.
- Use
EOFas the delimiter of a here-document; choose another word only when the body contains a lineEOF, for example a nested here-document. - Write redirections without a space, as
shfmtdoes:cat >"${file}" <<'EOF'.
You MUST NOT:
- Use unquoted variables in command arguments (except in specific contexts like
for item in ${list}).
Good examples:
# 'Single' quotes indicate that no substitution is desired.
# "Double" quotes indicate that substitution is required/tolerated.
# Variable expansion with double quotes
config_path="/etc/${app_name}.conf"
printf '%s is not supported.\n' "${operating_system}"
result="$(cat "${input_file}")"
# Static strings with single quotes
readonly log_level='info'
rm -f '/tmp/cache.tmp'
# Variables in test expressions
if [ -n "${username}" ]; then
printf '%s\n' "Hello, ${username}"
fi
# Mixed quoting to prevent complicated escaping
printf '%s\n' "it's working"
# Here-document without expansion: the body is written as it is
cat >"${config_file}" <<'EOF'
PATH="${HOME}/bin:${PATH}"
EOF
# Here-document with intended expansion
cat >"${config_file}" <<EOF
owner=${username}
EOFBad examples:
# Missing braces around variable names
config_path="/etc/$app_name.conf" # Ambiguous: is it $app_name or $app?
# Unquoted variables
rm -f $filepath # Breaks on spaces, glob expansion
cat $input_file # Word splitting issues
# Double quotes for static strings (unnecessary)
readonly log_level="info" # Single quotes preferred
# Unquoted variable in test
[ -n ${var} ] # Breaks if var is empty or unset
# Missing quotes in command substitution argument
result="$(cat ${input_file})" # Should be "${input_file}"
# Needless escaping single quotes within single-quoted strings
printf '%s\n' 'it'\''s working'
# Does only work in Bash
printf '%s\n' "The word for today is \"Foo\""
# Does not work in any shell
printf '%s\n' 'it\'s working'
# Technically working but complicated escaping. Prevent if possible.
printf '%s\n' 'This "is" correct and it'\''s working' # even breaks some syntax highlighters
printf '%s\n' "This "\""is"\"" correct and it's working"
printf '%s\n' "The word for today is "\"Foo\" # these are...
printf '%s\n' "The word for today is "\""Foo"\" # ...the sameReasoning:
- Quoting prevents word splitting and glob expansion, which are common sources of bugs and security vulnerabilities.
- Braces (
${var}) around variable names prevent ambiguity in string interpolation. - Single quotes are safer for static strings as they prevent any form of expansion.
- Proper quoting is essential for handling filenames with spaces and special characters.
- Unquoted variables in test expressions can cause syntax errors or unexpected behavior when empty.
- Escaping / including identical quotes within quotes in shell is not working as expected for programmers used to other languages.
- An unquoted here-document delimiter silently expands
$, backticks and backslashes in generated files, scripts and configuration. That is a common source of broken output and, with untrusted values, of injection.
You MUST:
-
Use lowercase with underscores for local variables:
my_variable. -
Use UPPERCASE for exported environment variables:
MY_ENV_VAR. -
Use UPPERCASE for constants (
readonly/local -r):MY_ENV_VAR. -
Always wrap variable names in braces:
${variable}, not$variable. -
Quote variable assignments:
foo='bar'orbar="${baz}". -
Use
set -uand initialize variables before use. -
Declare and assign separately when assigning a command substitution with an independent return value to avoid masking return values:
FOO="$(mycmd)" export FOO local CONFIG_FILE CONFIG_FILE='/etc/foobar' readonly CONFIG_FILE local bar='baz'
Capturing
$?is the exception:local status="$?"keeps the status, because$?is expanded beforelocalruns. Assign it in the first statement of the function ortraphandler, before any other command replaces it:cleanup() { local status="$?" rm -f -- "${tmp_file}" exit "${status}" } trap cleanup EXIT
You SHOULD:
- Use
readonlyfor constants and configuration values. - Use
localfor function-local variables (note: not POSIX, but widely supported). - Use meaningful, descriptive variable names.
You MUST NOT:
- Use UPPERCASE for local script variables (avoid accidental conflicts with
environment variables such as
PATH,HOME,USER).
Good examples:
# Local variables: lowercase with underscores
log_level='info'
user_count="${1:-0}"
# Exported variables: UPPERCASE
export MY_APP_DEBUG='true'
export DATABASE_URL="${db_host}:${db_port}"
# Always use braces
message="${greeting}, ${user_name}!"
# Using readonly and UPPERCASE for constants
readonly CFG_WORKINGDIR="/tmp"
# Declare and assign in the same line is OK here because the values are literals
# and not a command substitution with an independent return value.
export FOOBAR='baz'
readonly CONFIG_FILE='/etc/myapp.conf'
# Declare and assign separately when assigning a command substitution
FOO="$(mycmd)"
export FOO
readonly FOO
local bar
bar="$(mycmd)"
readonly bar
# Check if variable is set (compatible with set -u)
if [ -z "${CONFIG_PATH:-}" ]; then
printf '%s\n' 'CONFIG_PATH is not set' >&2
fiBad examples:
# UPPERCASE for local variables (risky)
HOME='/custom/path' # Overwrites system HOME!
USER='admin' # Overwrites current user variable
# Missing braces
message="$greeting, $user_name!" # Ambiguous
# Unquoted assignment
foo=bar # Works, but inconsistent with other assignments
# Missing braces causes wrong interpolation
file="$name_backup.txt" # Tries to expand $name_backup, not $name
# Return value of mycmd is ignored, export/local will always return true.
# This may prevent conditionals and traps from working correctly.
export FOO="$(mycmd)"
local bar="$(mycmd)"Reasoning:
- The lowercase/UPPERCASE convention is a well-established practice that prevents accidental overwriting of important environment variables.
- Braces eliminate ambiguity when concatenating variables with other text.
- Quoting assignments is technically optional in most cases, but consistent quoting prevents edge-case bugs and improves readability.
- Using
${var:-}allows checking for unset variables even withset -uenabled. - Since
localis widely supported (bash,dash,ash,zsh, FreeBSDsh) andkshcompatibility is rarely required, usinglocalis acceptable for most use cases. Document the limitation ifkshsupport is needed. - Combining declaration and assignment (
export foo="$(cmd)"orlocal foo="$(cmd)") masks the command's return value becauseexport/localalways return true, breaking error handling via conditionals,set -e, and traps (cf. SC2155).
You MUST:
- Use lowercase with underscores for function names:
my_function. - Define functions without the
functionkeyword (it is a non-portable Bashism). - Use parentheses without spaces after the function name:
my_function() { ... }. - Prefix library functions with the library name and an underscore:
my_library_my_function() { ... }. Names containing::are not supported by all target shells; for example,dashrejects them during parsing.
You SHOULD:
- Group
localvariable declarations at the beginning of functions. - Return explicit exit codes from functions.
- For scripts with multiple functions (not required on very short scripts with
simple linear flow):
- Use
main()as the bottom-most function. - Last non-comment line should call it:
main "$@"
- Use
Good examples:
###
# Process a configuration file and validate its contents.
# Arguments:
# $1 - Path to the configuration file.
# $2 - Expected configuration version (optional, default: "1.0").
# Returns:
# 0 if successful, 1 if file not found, 2 if validation fails.
process_config() {
local config_path="${1}"
local expected_version="${2:-1.0}"
if [ ! -f "${config_path}" ]; then
printf '%s\n' "Error: Config file not found: ${config_path}" >&2
return 1
fi
# Validate configuration
if ! validate_config "${config_path}" "${expected_version}"; then
return 2
fi
return 0
}
###
# Print a formatted error message to STDERR.
# Arguments:
# $1 - The error message to display.
# Outputs:
# Writes error message to STDERR.
print_error() {
printf '%s: %s\n' "Error" "${1}" >&2
return 0
}
###
# Main entry point.
# Arguments:
# $@ - Command-line arguments.
main() {
if [ "$#" -lt 1 ]; then
print_error "Missing required argument"
exit 2
fi
process_config "${1}"
}
# Call main at the end of the script
main "$@"The main pattern keeps execution flow clear and allows functions to be defined
in logical order. An unconditional main "$@" also runs when the file is
sourced. For portable reuse, put function definitions in a separate library
without an entry-point call, and source that library from a thin executable
wrapper. Resolve the library through the project's installation layout, not the
caller's working directory.
Do not use (return 0 2>/dev/null) to detect sourcing: some shells, including
dash, accept it even when the script is executed directly. For a Bash-only
file that intentionally supports both execution and sourcing, guard main with:
if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
main "$@"
fiThis guard only controls the call to main; other top-level commands still run
when the file is sourced. Test both invocation modes when both are supported.
Bad examples:
# Using function keyword (Bashism)
function process_config() {
printf '%s\n' 'Processing...'
}
# Missing documentation
process_config() {
local config_path="${1}"
# What does this function do? What are the parameters?
}
# No space before brace, inconsistent style
process_config(){
printf '%s\n' 'Processing...'
}Reasoning:
- The
functionkeyword is a Bash extension not available in POSIX shells like dash. - Explicit return codes make error handling predictable and testable.
- The
localkeyword is not POSIX but is widely supported (bash, dash, ash, zsh). For maximum portability with ksh, group variable declarations visibly so they can be easily converted. main()provides consistency and allows to define more variables as local (which can't be done outside of functions).
You MUST:
- Use
$(command)syntax for command substitution. - Never use backticks (
`command`) for command substitution.
You SHOULD:
- Quote command substitutions when assigning to variables:
var="$(command)". - Nest command substitutions when needed (easy with
$()syntax).
Good examples:
# Modern command substitution syntax
current_date="$(date '+%Y-%m-%d')"
file_count="$(find "${dir}" -type f | wc -l)"
# Nested command substitution
backup_name="$(basename "$(dirname "${path}")")"
# Command substitution in conditionals
if [ "$(id -u)" -ne 0 ]; then
printf '%s\n' 'This script requires root privileges.' >&2
exit 1
fiBad examples:
# Backticks (deprecated, hard to nest, hard to read)
current_date=`date '+%Y-%m-%d'`
file_count=`find "${dir}" -type f | wc -l`
# Nested backticks (very confusing)
backup_name=`basename \`dirname "${path}"\``Reasoning:
- The
$()syntax is POSIX-compliant and supported by all modern shells. - The
$()syntax is more readable and easier to nest. - Backticks are a legacy syntax from the Bourne shell era and require complex escaping for nesting.
You MUST:
- Use
[ ](single brackets) for test expressions, not[[ ]](double brackets are a Bashism). - Use
=for string equality, not==(the latter is a Bashism in[ ]). - Quote all variables in test expressions.
You SHOULD:
- Use
-zto test for empty strings and-nfor non-empty strings. - Use
&&and||outside the brackets for compound conditions. - Use
grepfor pattern matching instead of[[ =~ ]].
Good examples:
# String comparisons
if [ "${answer}" = 'yes' ]; then
printf '%s\n' 'Confirmed'
fi
# Testing for empty/non-empty strings
if [ -z "${username}" ]; then
printf '%s\n' 'Username is required' >&2
exit 1
fi
if [ -n "${verbose}" ]; then
printf '%s\n' 'Verbose mode enabled'
fi
# File tests
if [ -f "${config_file}" ] && [ -r "${config_file}" ]; then
. "${config_file}"
fi
# Numeric comparisons
if [ "${count}" -gt 10 ]; then
printf '%s\n' 'Count exceeds limit'
fi
# Pattern matching with grep (POSIX-compliant)
if printf '%s' "${input}" | grep -E -q '^[0-9]+$'; then
printf '%s\n' 'Input is a number'
fi
# Substring check with grep
if printf '%s' "${haystack}" | grep -F -q 'needle'; then
printf '%s\n' 'Found needle in haystack'
fiBad examples:
# Double brackets (Bashism)
if [[ "${answer}" == 'yes' ]]; then
printf '%s\n' 'Confirmed'
fi
# Using == in single brackets (Bashism in test)
if [ "${answer}" == 'yes' ]; then
printf '%s\n' 'Confirmed'
fi
# Regex in double brackets (Bashism)
if [[ "${input}" =~ ^[0-9]+$ ]]; then
printf '%s\n' 'Input is a number'
fi
# Unquoted variable in test (dangerous)
if [ -n ${var} ]; then # Breaks if var is empty
printf '%s\n' 'var is set'
fi
# Comparing against empty string (prefer -z)
if [ "${var}" = '' ]; then
printf '%s\n' 'var is empty'
fiCommon test operators:
| Operator | Description |
|---|---|
-z STRING |
True if string is empty (zero length) |
-n STRING |
True if string is not empty (non-zero length) |
STR1 = STR2 |
True if strings are equal |
STR1 != STR2 |
True if strings are not equal |
-f FILE |
True if file exists and is a regular file |
-d FILE |
True if file exists and is a directory |
-r FILE |
True if file exists and is readable |
-w FILE |
True if file exists and is writable |
-x FILE |
True if file exists and is executable |
-s FILE |
True if file exists and has size > 0 |
-L FILE |
True if file exists and is a symbolic link |
INT1 -eq INT2 |
True if integers are equal |
INT1 -ne INT2 |
True if integers are not equal |
INT1 -lt INT2 |
True if INT1 < INT2 |
INT1 -le INT2 |
True if INT1 <= INT2 |
INT1 -gt INT2 |
True if INT1 > INT2 |
INT1 -ge INT2 |
True if INT1 >= INT2 |
POSIX character classes
for grep, sed, tr:
| Class | Equivalent | Description |
|---|---|---|
[:alnum:] |
[A-Za-z0-9] |
Letters and digits |
[:alpha:] |
[A-Za-z] |
Letters |
[:lower:] |
[a-z] |
Lowercase letters |
[:upper:] |
[A-Z] |
Uppercase letters |
[:digit:] |
[0-9] |
Digits |
[:xdigit:] |
[A-Fa-f0-9] |
Hexadecimal digits |
[:space:] |
[ \t\r\n\v\f] |
Whitespace characters |
[:blank:] |
[ \t] |
Space and tab only |
[:cntrl:] |
[\x00-\x1F\x7F] |
Control characters |
[:graph:] |
[\x21-\x7E] |
Printable characters, not including space |
[:print:] |
[\x20-\x7E] |
Printable characters, including space |
Reasoning:
- The
[[ ]]syntax is a Bash/Zsh extension not available in POSIX shells. - Double equals (
==) is not part of the POSIX test specification. - Using
grepfor pattern matching is portable and often clearer than regex operators. - Proper quoting in tests prevents word splitting and syntax errors.
You MUST:
- Use
printfinstead ofechofor output. - Never put variables directly in the format string: use
printf '%s\n' "${var}", notprintf "${var}". - Write error messages to
STDERRusing>&2.
You SHOULD:
- Include a trailing newline in output:
printf 'message %s\n' 'whatever'. - Use format specifiers appropriately:
%sfor strings,%dfor integers.
Good examples:
# Basic output with printf
printf '%s\n' 'Starting process...'
# Output with variables (safe)
printf '%s\n' "${message}"
printf 'User: %s, ID: %d\n' "${username}" "${user_id}"
# Error output to STDERR
printf '%s: %s\n' 'Error' 'File not found' >&2
# Multi-line output
printf '%s\n' 'Line 1' 'Line 2' 'Line 3'
# Formatted output
printf '%-20s %10d\n' 'Total files:' "${file_count}"Bad examples:
# Using echo (behavior varies across implementations)
echo 'Starting process...'
echo -e 'Tab:\there' # -e not portable
echo -n 'No newline' # -n not portable
# Variable in format string (format string vulnerability)
printf "${user_input}" # DANGEROUS: user can inject format specifiers
# Missing error redirection
printf '%s\n' 'Error: something failed' # Should go to STDERRReasoning:
- The behavior of
echovaries significantly across implementations (handling of-e,-n, backslash escapes). POSIX does not define the behavior when the first argument starts with-or contains backslashes. printfis consistent and well-defined across all POSIX shells.- Putting variables in the format string creates a format string vulnerability
where special characters like
%sor%nin the input could cause unexpected behavior or crashes. - Error messages should go to STDERR so they can be handled separately from normal output.
You MUST:
- Use
set -uto treat unset variables as errors. - Use exit code 2 for command-line syntax errors (following Unix convention).
- Write error messages to
STDERRusing>&2.
You SHOULD:
- Check command exit status explicitly for important operations.
- Provide meaningful error messages that include the script name.
- Use
trapfor cleanup operations.
You SHOULD NOT:
- Use
set -e(it has many edge cases and can cause unexpected behavior). - Use
set -a(allexport) globally; if needed, use it locally and reset immediately.
Error logging function:
For long-running scripts or daemons, include a timestamp in error messages:
###
# Print a timestamped error message to STDERR.
# Arguments:
# $@ - The error message components.
# Outputs:
# Writes timestamped error message to STDERR.
err() {
printf '[%s]: %s\n' "$(date +'%Y-%m-%dT%H:%M:%S%z')" "$*" >&2
}
# Usage
err "Failed to connect to database"
# Output: [2024-01-15T14:30:22+0100]: Failed to connect to databaseGood examples:
#!/usr/bin/env sh
set -u # Error on unset variables
# Explicit error handling
if ! mkdir -p "${output_dir}"; then
printf '%s: Failed to create directory: %s\n' "$(basename "${0}")" "${output_dir}" >&2
exit 1
fi
# Check command success with meaningful message
if ! curl --silent --fail --output "${output_file}" "${url}"; then
printf '%s: Download failed: %s\n' "$(basename "${0}")" "${url}" >&2
exit 1
fi
# Using exit code 2 for usage errors
if [ "$#" -lt 1 ]; then
printf 'Usage: %s <input_file>\n' "$(basename "${0}")" >&2
exit 2
fi
# Cleanup on exit using trap
cleanup() {
rm -f "${temp_file:-}"
}
trap cleanup EXIT
# Handle pipeline failures explicitly (POSIX-compliant way)
if ! grep 'pattern' file.txt | sort >output.txt; then
# Note: This only checks if 'sort' succeeded
# For the grep exit status, use a temporary variable or file
:
fi
# More reliable pipeline error handling
grep 'pattern' file.txt >temp.txt
grep_status="$?"
if [ "${grep_status}" -gt 1 ]; then
# grep returns 1 for "no matches", 2+ for errors
printf '%s\n' 'grep failed' >&2
exit 1
fi
sort temp.txt >output.txt || exit 1Reasoning:
set -ucatches typos in variable names and prevents silent failures from unset variables.- Exit code 2 is the Unix convention for syntax errors (used by shell builtins and most utilities).
- Appropriate traps can clean up on normal exit and relevant catchable signals.
They cannot run after
SIGKILLor abrupt power loss. set -edepends on the execution context (see the Bash manual):-
A plain assignment such as
var=$(false)returns the substitution's failure status and can terminate the outer shell. In Bash's default non-POSIX mode, however,-eis cleared inside command substitutions unlessinherit_errexitis enabled:var=$(false; printf '%s' 'continued')can succeed because the final command succeeds. -
Without
pipefail, the pipeline status is the last command's status:false | truesucceeds. -
Failures used as conditions, negated with
!, or in non-final positions of&&/||lists do not trigger it. A function called in such a context also runs with-eignored inside its body; extracting code into that function can therefore change failure handling. -
Behavior varies between shells and shell versions.
-
If you still want to use
set -e, be aware of these limitations and combine it with explicit error handling for critical operations. When Bash is required and you choose to useset -e(for whatever reason), these additional options help:#!/usr/bin/env bash set -e # Exit on error set -u # Error on unset variables set -o pipefail # Include failing pipeline stages in the exit status shopt -s inherit_errexit # Preserve set -e in command substitutions (Bash 4.4+)
inherit_errexitis Bash-specific and requires Bash 4.4 or newer. POSIX.1-2024 specifiespipefail, but older target shells may not implement it. These options do not remove the conditional-context exceptions above.Exception: Use
set -efor simple, linear scripts where any failed command should stop the script, such as release checks with many build, lint, and packaging steps. This is cleaner than adding|| exit 1everywhere. Pair it with the four Bash options above and add a comment explaining why it is used. Avoidset -ein scripts with branches, important pipeline statuses, or command substitutions used in logic.
-
set -aexports all subsequently defined variables to the environment, which can pollute child processes and cause unexpected behavior.
You MUST:
- Start each file with a description of its contents.
- Write comments and documentation in US English.
- Document functions with a comment block (omit sections that don't apply):
- Short and simple description of the function.
- Globals: List of global variables used and/or modified.
- Arguments: Arguments taken (use None if no args).
- Outputs: Describing output to STDOUT and/or STDERR.
- Returns: Returned values other than the default exit status of the last command run.
You SHOULD:
- Use sentence case for comments (capitalize the first word only, unless proper nouns are involved).
- Write comments that explain "why", not "what" (the code shows what).
Good examples:
#!/usr/bin/env sh
#
# Perform a full backup of the Zammad ticketing system database.
###
# Remove temporary files from the build directory.
# Globals:
# BUILD_DIR
# VERBOSE
# Arguments:
# None
cleanup_build() {
rm -rf "${BUILD_DIR}/tmp"
}
###
# Remove a file with validation.
# Arguments:
# $1 - Path to the file to delete.
# Returns:
# 0 if file was deleted, 1 if file does not exist, 2 on permission error.
remove_file() {
local file_path="${1}"
if [ ! -e "${file_path}" ]; then
return 1
fi
rm "${file_path}" || return 2
}
###
# Retrieve the application configuration directory path.
# Globals:
# APP_HOME
# Arguments:
# None
# Outputs:
# Writes directory path to STDOUT.
get_config_dir() {
printf '%s\n' "${APP_HOME}/config"
}Style recommendations:
| Rule | Good Example | Bad Example |
|---|---|---|
| Use active voice | The script creates a backup | A backup is created by the script |
| Use present tense | This function returns | This function will return |
| Use American English | The color of the output | The colour of the output |
Reasoning:
- English is the lingua franca of software development.
- Consistent style makes documentation easier to read and maintain.
- Comments explaining "why" remain valuable even when code changes.
- Documentation blocks help maintainers understand the code without reading the implementation. The format follows Google's Shell Style Guide.
Shell snippets in Markdown and other documentation follow this guide like scripts, with the exceptions below. The Markdown style guide covers fences and their language.
You MUST:
- Keep snippets valid for
shfmtand ShellCheck with the dialect the fence names (shfor POSIX,bashfor Bash). ShellCheck's SC2154 (referenced but not assigned) and SC2034 (assigned but unused) do not apply to variables that other snippets or the text set or use. - Write values the reader supplies as quoted variables that are set earlier or
explained in the text (
"${domain_uuid}"), not as<domain>placeholders, unless the block is markedtextand is not meant to be run. - Fence commands the reader must not run, such as destructive or forbidden
examples, as
text.
You MAY:
- Omit the shebang, the file header comment,
main()and the boilerplate. - Omit
readonlyand UPPERCASE for values the reader may change and run again in the same shell. - Keep the fence language for bad examples that demonstrate a rule violation,
as in style guides, even when they fail
shfmtor ShellCheck.
Good examples:
domain_uuid="$(virsh domuuid "${domain}")"
virsh destroy "${domain_uuid}"A broad cleanup the reader must not copy:
podman rm -f $(podman ps -aq)
Bad examples:
virsh destroy <domain-uuid>Reasoning:
- Readers and tools copy snippets into shells and scripts. A placeholder like
<domain-uuid>is a redirection in the shell, so copying it runs something other than intended or fails with a confusing error. - Snippets are rerun step by step in one interactive shell, where
readonlywould refuse the next assignment. - A snippet is a fragment of a session: its variables come from earlier snippets or the text, so ShellCheck cannot see where they are set or used.
- A
textfence tells readers and tools that extract, run or lint snippets that a command is not meant to be run.
You MUST:
- Target POSIX-compliant shells (
sh,dash,bash,ash). - Check for command availability before using non-standard utilities.
- Use
./*instead of bare*when passing glob results to commands that interpret leading dashes as options. - End options with
--before operands that come from variables or user input, when the command supports it:rm -f -- "${file}",mv -- "${source}" "${target}",grep -e "${pattern}" -- "${file}". For commands without--, such asprintfwith its format argument, keep the operand from starting with-, as./*does for globs.
You SHOULD:
-
Use only POSIX-defined utilities and their POSIX-defined options when possible.
-
Set
LC_ALLto a locale that actually exists on the target system. Prefer a UTF-8 locale, but fall back toCif none is available:if command -v locale >/dev/null 2>&1; then for locale_candidate in 'C.UTF-8' 'C.utf8' 'en_US.UTF-8' 'UTF-8' 'C'; do if LC_ALL="${locale_candidate}" locale charmap >/dev/null 2>&1; then export LC_ALL="${locale_candidate}" break fi done else export LC_ALL='C' fi
Do so until your script explicitly has to follow a system's localization.
-
Set a
PATHfallback:export PATH="${PATH:-/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin}"
-
Test scripts with
dashduring development (it is stricter about POSIX compliance). -
For the guide's broad shell baseline, avoid relying on
pipefail; use explicit status handling for important pipeline stages. If an executable deliberately disables an inherited setting, first check whether the shell supports it:if set -o 2>/dev/null | grep -Fq 'pipefail'; then set +o pipefail fi
A sourced library should not change its caller's shell options unless that is part of its documented contract. The Bash-only linear-script exception is described under Error handling.
-
Avoid GNU-specific options (long options like
--verboseare often not portable even if they are improving readability). -
Document any required non-POSIX features or tools.
Reasoning:
-
dashis a minimal POSIX shell that catches many Bashisms during development. -
POSIX compliance ensures scripts work across Linux distributions (
bash,dash,ash), macOS (usually a very old Bash 3.2 orzsh), and BSDs (ash-basedshorksh). -
GNU long options (
--option) are convenient but not available on BSD systems (including macOS) without GNU coreutils. -
Using
command -vis the POSIX-compliant way to check for command availability (preferred overwhich,hash, ortype). -
Safe glob expansion is needed as filenames can start with a hyphen (e.g.,
-rf), which commands may interpret as options. Using./*prevents this:# Bad - fails if a file is named "-rf" or "--help" rm * cat * # Good - ./ prefix prevents interpretation as options rm ./* cat ./* # Also works with loops for file in ./*; do process_file "${file}" done
-
The same failure happens with any variable operand: a value such as
-rfbecomes an option.--marks the end of the options as defined by the POSIX utility syntax guidelines and is supported by the common utilities;./*only covers globs.# Bad - a file named "-rf" is read as options rm -f "${file}" # Good rm -f -- "${file}"
-
Setting
LC_ALLensures predictable sorting, character classification, and text processing. Without explicit locale settings, script behavior may vary between systems.LC_ALLoverrides all other locale variables (LANGand individualLC_*settings), so it alone is sufficient. -
Hard-coding
en_US.UTF-8is fragile on minimal containers and embedded systems because that locale is often not generated there. Probing for a working locale avoids noisy warnings such assetlocale: LC_ALL: cannot change locale. -
C.UTF-8is commonly available on modern minimal systems and keeps Unicode handling predictable. Falling back to plainCstill gives deterministic byte-oriented behavior when no UTF-8 locale exists. -
pipefailis specified by POSIX.1-2024, but a standard's publication does not upgrade installed shells. Availability depends on the target implementation and version. This guide retains explicit pipeline-status handling for compatibility with older deployments; neither a current Bash run nor a genericshlabel proves support across the matrix.
Checking for required commands:
# Check if required command is available
if ! command -v 'jq' >/dev/null 2>&1; then
printf '%s: %s\n' 'Error' 'jq is required but not installed' >&2
exit 1
fi
# Check multiple commands
for cmd in 'curl' 'jq' 'openssl'; do
if ! command -v "${cmd}" >/dev/null 2>&1; then
printf '%s: "%s" is required but not installed.\n' 'Error' "${cmd}" >&2
exit 1
fi
done
# Check using check_cmd()/require_cmd() from shell-boilerplate.sh
require_cmd 'curl' 'jq' 'openssl' # exits with an error message if any is missing
check_cmd 'jq' && printf '%s\n' 'jq is available' # returns 1 instead of exitingBasic local testing with alternate shells:
sudo dnf install dash busybox oksh
# Test script.sh syntax with multiple shells
for shell in dash sh bash oksh; do
if command -v "${shell}" >/dev/null 2>&1; then
printf 'Syntax check with %s: ' "${shell}"
if "${shell}" -n ./script.sh 2>&1; then
printf 'OK\n'
else
printf 'FAILED\n'
fi
fi
donePOSIX-compliant utilities:
Common POSIX utilities that can be relied upon (see
Open Group Base Specifications, IEEE Std 1003.1-2017: Shell & Utilities:
4. Utilities):
awk, basename, cat, chmod, chown, compress, cp, cut, date,
dirname, env, expr, find, grep, head, id, kill, ln, ls,
mkdir, mkfifo, mv, od, paste, printf, pwd, read, rm, rmdir,
sed, sleep, sort, tail, tee, test, touch, tr, true, false,
umask, uname, uncompress, uniq, wc, xargs, zcat.
The shell-boilerplate.sh file provides a starting
point for new scripts. It includes environment setup, utility functions, and a
structured main() pattern that follows this style guide.
The code between the --- BOILERPLATE START|END v<version> --- markers is a
menu, not a mandate: copy in only the parts a script needs. It provides:
- Consistent environment setup (
PATHfallback, locale probe withLC_ALLfallback,set -u,pipefaildisabled). - ANSI formatting codes respecting
NO_COLOR. msg()for formatted output (errors, warnings, success, info, debug) with optional timestamps.check_cmd()andrequire_cmd()for checking command availability.ensure()for running commands that must not fail.
Take the full block for complex, long-running scripts of the size and shape of
pve_backup_usb.sh:
many functions, cleanup on exit, several command checks, where consistent
logging and error handling pay off. Small scripts can take just the environment
setup and skip the rest.
Keep the version markers only on a script that carries the block in full; they exist so it can be updated programmatically later (see Updating the boilerplate). A partial copy should be pasted plainly, without markers.
See shell-boilerplate.sh.
Scripts that carry the full, marker-delimited block can pull the latest version straight from GitHub with the standalone snippet below. It replaces only the content between the markers and leaves the rest of the target script untouched:
#!/usr/bin/env sh
#
# Update the inline boilerplate block in a script from the canonical
# shell-boilerplate.sh. Only replaces the content between the
# "--- BOILERPLATE START|END v<version> ---" markers.
#
# Usage: update-boilerplate.sh <script>
set -u
target="${1:?Usage: update-boilerplate.sh <script>}"
url='https://raw.githubusercontent.com/foundata/guidelines/master/shell-boilerplate.sh'
if [ ! -f "${target}" ]; then
printf '%s: not a file: %s\n' "$(basename "${0}")" "${target}" >&2
exit 1
fi
new="$(mktemp)" && tmp="$(mktemp)" || exit 1
trap 'rm -f "${new}" "${tmp}"' EXIT INT TERM
if ! curl --fail --silent --show-error --location --output "${new}" "${url}"; then
printf '%s: download failed: %s\n' "$(basename "${0}")" "${url}" >&2
exit 1
fi
exec_bit='0'
[ -x "${target}" ] && exec_bit='1'
awk '
NR == FNR {
if ($0 ~ /^# --- BOILERPLATE START v/) { inblock = 1 }
if (inblock) { block = block $0 "\n" }
if ($0 ~ /^# --- BOILERPLATE END v/) { inblock = 0 }
next
}
/^# --- BOILERPLATE START v/ { printf "%s", block; skip = 1 }
/^# --- BOILERPLATE END v/ { skip = 0; next }
!skip { print }
' "${new}" "${target}" >"${tmp}"
cp "${target}" "${target}.bak"
mv "${tmp}" "${target}"
[ "${exec_bit}" = '1' ] && chmod +x "${target}"
printf '%s: updated %s (backup: %s.bak)\n' "$(basename "${0}")" "${target}" "${target}"Review the diff before committing: the markers only bound the replacement, they do not guarantee the new version is a drop-in fit (e.g. if custom code was pasted between them). Keep the update as its own commit, scoped to just the boilerplate change, e.g.:
<component>: update shell boilerplate to v<version>
The shell-snippets.sh file provides a collection of
commonly needed shell actions ready for copy & paste. It serves as a reference
and quick-start resource for recurring tasks.
Please note:
- All simple command snippets are POSIX compatible (except when calling external
non-POSIX commands like
curlorwget). - Functions may rely on other functions from
shell-boilerplate.sh.
Categories included:
- Variables and string handling: Case conversion, whitespace trimming, duplicate line removal, string length, list item counting, NUL byte stripping, user input reading, search and replace.
- Environment: Retrieving user home directories reliably across different environments.
- Process control: Ensuring single script instance execution, timeout handling for stale processes.
- User interaction: Reading user input, prompts with defaults, yes/no confirmation dialogs, path validation.
- Files: Directory copying with
rsync, file downloads withcurl/wgetfallback.
See shell-snippets.sh.
This guide was written by foundata to produce robust, readable, and portable shell scripts. It incorporates lessons learned from real-world cross-platform shell scripting.
For general background, see: