POSIX Shell Scripting Style Guide (Version 1.0)
For a compact summary, see the POSIX Shell Scripting Cheat Sheet.
1. Scope and Portability
This guide applies to scripts written for the POSIX shell language. Scripts must run with a POSIX-conforming /bin/sh; do not rely on Bash, Z shell, or other shell extensions.
Use only POSIX shell syntax and POSIX utilities unless the script explicitly documents a required non-POSIX dependency. Run ShellCheck with the sh shell dialect for shell source files that do not begin with a shebang.
Executable scripts begin with #!/bin/sh. Shell fragments intended to be sourced begin with # shellcheck shell=sh instead.
Use set -eu near the start of executable scripts. Handle commands that may fail as part of normal control flow explicitly with if, ||, or &&.
Examples:
#!/bin/sh
set -eu
if ! output=$( commandThatMayFail )
then
fail "Could not create output"
fi
2. General Layout
All comments and identifiers must be in English.
Indentation uses tabs. A tab counts as 4 spaces when it must be counted as spaces. Tabs are used strictly for indentation; use spaces only for alignment in text output and here documents.
Source lines must not exceed 80 characters. The line break counts as a character, so the last visible source character may only occupy column 79.
Leave two blank lines between top-level functions. Inside a function, leave at most one blank line to group related commands. Use a grouping comment when more separation is necessary.
Break a long command before a shell control operator, pipeline, redirection, or command substitution argument. Continue the command on the next indented line and place \\ at the end of every continued line.
Examples:
result=$( commandOne "$input" \\
| commandTwo \\
| commandThree )
[ -f "$path" ] \\
|| fail "File not found: $path"
3. Naming and Variables
Use camelCase for function names and variables. Use UPPER_CASE with underscores for environment variables, exported variables, and true constants.
Prefix variables that are private to a function with a short function-specific underscore prefix. This prevents accidental collisions because POSIX shell has no local variables. Do not use local, which is not specified by POSIX.
Use descriptive names. Avoid single-letter names except for conventional short-lived loop variables.
Assign variables without spaces around =. Quote command substitutions and parameter expansions unless unquoted expansion is explicitly required.
Examples:
MARKSHUP_DEFAULT_CSS_PATH=${MARKSHUP_DEFAULT_CSS_PATH:-default.css}
markshupOutputPath( )
{
_mop_name=$1
_mop_extension=${_mop_name##*.}
printf '%s\n' "$_mop_extension"
}
4. Quoting and Expansions
Always quote parameter expansions, command substitutions, and variable assignments that may contain arbitrary text. Use "$@" to forward positional arguments unchanged.
Use ${name} when concatenating a variable with literal text or when it makes the expansion boundary clearer. Use $name for a simple standalone expansion.
Do not use unquoted command substitution to construct argument lists. Do not split file names on whitespace or parse ls output.
Avoid eval when direct parameter expansion, functions, or case can express the operation. Use it only when POSIX shell has no direct alternative, for example to access a variable whose validated name is dynamic or to execute a command assembled from correctly quoted parts.
Never pass unvalidated or externally controlled text to eval. Validate dynamic variable names before expanding them, and construct evaluated commands from literals or safely quoted data only.
Use $( ... ) for command substitution. Do not use legacy backticks.
Examples:
command "$inputPath" "$outputPath"
baseName=${path##*/}
outputPath=${directory}/${baseName%.md}.html
for argument
do
processArgument "$argument"
done
dynamicValue( )
{
case $1 in
[!A-Za-z_]*|*[!A-Za-z0-9_]*) return 1 ;;
esac
eval "_dv_value=\${$1}"
printf '%s\n' "$_dv_value"
}
5. Commands, Output, and Exit Status
Use printf instead of echo. Always supply a format string, and quote data arguments.
Write normal output to standard output and diagnostics to standard error. Functions that produce a value print only that value to standard output. Return a non-zero status for failure.
Use [ ... ] for portable tests. Quote operands that may be empty or contain special characters. Prefer case for pattern matching and for testing path or option forms.
Use command -v to check whether a command is available. Do not use which.
Examples:
fail( )
{
printf 'tool: %s\n' "$1" >&2
return 1
}
if command -v curl > /dev/null 2>&1
then
curl -fL "$url" -o "$outputPath"
fi
6. Functions and Parameters
Declare functions using the POSIX name( ) form. Place the opening { on the next line. Do not use the non-POSIX function keyword.
Document positional parameters immediately above a function using $1, $2, and $@. State the meaning of every parameter, including parameters that carry output paths or return values.
Functions receive input through positional parameters and report results through standard output and their exit status. Avoid modifying global state unless the function is specifically intended to configure it.
Call functions without spaces between the function name and its arguments. Quote every argument unless it is an intentional literal or shell operator.
Examples:
# $1 - Existing file path.
# $2 - Destination directory.
copyFile( )
{
[ -f "$1" ] || return 1
cp "$1" "$2"
}
copyFile "$inputPath" "$outputDirectory" \\
|| fail "Could not copy input file"
7. Control Flow
Put then, do, else, elif, esac, and done on their own lines. Indent their bodies by one tab.
Use if for status tests and case for matching options, file extensions, and other patterns. Quote the value after case; do not quote shell patterns.
Keep each case arm indented one tab. End every arm with ;;, except an arm that exits the surrounding function or script.
Use while IFS= read -r line to read input lines without trimming whitespace or interpreting backslashes. Preserve IFS when changing it temporarily, and restore it before returning.
Examples:
case $option in
--help)
usage
return
;;
-*)
fail "Unknown option: $option"
;;
*)
inputPath=$option
;;
esac
while IFS= read -r line || [ -n "$line" ]
do
processLine "$line"
done < "$inputPath"
8. Files, Temporary Data, and Cleanup
Create temporary directories with mktemp -d. Store their paths in clearly named variables and remove them with a trap before work begins.
Quote every file path. Validate paths before use when the script accepts them from a caller. Do not use a predictable temporary file name or remove a path that has not been validated.
Prefer files and explicit loops over command substitutions when data may contain newlines. Use redirection to keep input and output paths visible at the command that uses them.
Examples:
_temporaryDirectory=$( mktemp -d "${TMPDIR:-/tmp}/tool.XXXXXX" ) \\
|| fail 'Could not create a temporary directory'
trap 'rm -rf "$_temporaryDirectory"' EXIT HUP INT TERM
while IFS= read -r path
do
processFile "$path"
done < "$pathList"
9. Comments and File Headers
Use # for comments. Comments explain intent, portability constraints, and non-obvious safety decisions rather than restating the command.
Put the file header directly after the shebang or ShellCheck directive. Keep copyright and SPDX information in the established project format.
Use parameter comments directly above functions. Use an empty # line to separate a file header from the following code when the surrounding project does so.
Examples:
# shellcheck shell=sh
#
# Copyright 2026 CodingMarkus
#
# SPDX-License-Identifier: AGPL-3.0-only
# $1 - Source URL.
# $2 - Destination file path.
#
# Downloads the source URL to the destination file with curl or wget.
10. Consistency and Readability Win
Keep related commands and case arms visually consistent. When two valid forms are available, prefer the form already used by the surrounding script.
Readability, portability, and correct handling of arbitrary file names outweigh compact shell idioms.