| name | shell-posix-style |
| description | Required style guidelines for writing shell scripts where POSIX-compliance is REQUIRED |
POSIX Shell Script Style Guide
When to Use POSIX Shell
IMPORTANT - REQUIRED POSIX COMPLIANCE: This rule applies when POSIX-compatibility IS a requirement. These guidelines are specifically for shell scripts that must work across different UNIX-like systems using only POSIX-compliant features:
- Use POSIX shell when: You need scripts to work on various UNIX-like systems, embedded environments, or when bash availability is uncertain
- Don't use POSIX shell when: You can guarantee bash availability and need advanced features like arrays or associative arrays
- Consider alternatives: For complex scripts (>100 lines), consider using a more structured language like Python or Go
- The complexity threshold is about maintainability by people other than the author.
Core Requirements
1. Shebang and Shell Selection
Always use POSIX sh for executable scripts:
#!/bin/sh
Use 'set' for POSIX shell options:
#!/bin/sh
set -eu
2. File Extensions
- Executables: Use
.sh extension OR no extension
- Use
.sh if build rules will rename the file
- Use no extension if script goes directly into user's PATH
- Libraries: Must have
.sh extension and should NOT be executable
3. File Header Comments
Every file must start with a description:
#!/bin/sh
Formatting Standards
1. Indentation
Use tabs for initial indentation:
if [ "$1" = "start" ]; then
echo "Starting service..."
if systemctl start myservice; then
echo "Service started successfully"
else
echo "Failed to start service" >&2
return 1
fi
fi
Use spaces for subsequent indentation:
if [ "$1" = "start" ]; then
echo "Starting service..."
if systemctl start myservice; then
report_start "myservice"
log "myservice started"
monitor_service "myservice"
else
echo "Failed to start service" >&2
return 1
fi
fi
ALWAYS use spaces for indentation within multi-line comments:
if [ "$1" = "start" ]; then
echo "Starting service..."
if systemctl start myservice; then
echo "Service started successfully"
else
echo "Failed to start service" >&2
return 1
fi
fi
2. Line Length
Keep lines under 80 characters when possible:
- DO NOT compromise readability or maintainability just to stay under 80, especially where subshells come into play
- PREFER to break sentences at sentence ends or logical subjects, rather than just at 80 characters
- PREFER to let a line go a little bit above 80 rather than having a stupid-short 2nd line
Break lines logically, not arbitrarily at 80 characters:
if [ "${enable_logging}" = "true" ] && [ -w "${log_directory}" ]; then
echo "Logging enabled to ${log_directory}"
fi
if [ "${enable_logging}" = "true" ] && [ -w "${log_directory}" \
]; then
echo "Logging enabled to ${log_directory}"
fi
Prefer slightly longer lines over awkwardly short continuation lines:
echo "Processing configuration file: ${config_file} with options: ${options}"
echo "Processing configuration file: ${config_file} with options: \
${options}"
For long commands, use line continuation with proper indentation:
command \
--option1 value1 \
--option2 value2 \
--option3 value3
command --option1 value1 --option2 value2 \
--option3 value3
For long strings, use here documents:
cat <<EOF
This is a long message that would exceed the 80 character limit
if written on one line.
EOF
echo "This is a long message that would exceed \
the 80 character limit if written on one line."
Break sentences at natural boundaries:
cat <<EOF
Starting backup process for database ${db_name}.
This may take several minutes depending on database size.
EOF
cat <<EOF
Starting backup process for database ${db_name}. This may take
several minutes depending on database size.
EOF
3. Pipelines
Put pipelines on separate lines when they become long:
ps aux | grep nginx
command1 \
| command2 \
| command3 \
| command4
4. Control Flow
Use proper spacing and alignment:
if [ "${condition}" ]; then
elif [ "${other_condition}" ]; then
else
fi
for file in "$@"; do
process_file "${file}"
done
while read -r line; do
echo "Processing: ${line}"
done < "${input_file}"
5. Case Statements
Align and indent consistently:
case "$1" in
start)
start_service
;;
stop)
stop_service
;;
restart)
stop_service
start_service
;;
*)
echo "Usage: $0 {start|stop|restart}" >&2
exit 1
;;
esac
Variable and Quoting Rules
1. Variable Expansion
Brace-delimit all named variables. Do not brace single-character shell specials or positional parameters unless required or avoiding confusion:
echo "Hello ${name}!"
echo "File: ${file}.backup"
echo "Positional: $1 $2"
echo "Count: $#, status: $?"
main "$@"
echo "${10}"
echo "${1}0${2}"
echo "Hello $name!"
echo "File: $file.backup"
echo "Positional: ${1}"
2. Quoting
Quote variables to prevent word splitting:
if [ -f "${config_file}" ]; then
cp "${config_file}" "${backup_dir}/"
fi
if [ -f $config_file ]; then
cp $config_file $backup_dir/
fi
Quote all strings except in specific contexts:
echo "Starting process: ${process_name}"
grep "pattern" "${file}"
count=$(expr ${count} + 1)
if [ "${count}" -gt 10 ]; then
echo "Count exceeded limit"
fi
Function Standards
1. Function Names
Use lowercase with underscores (snake_case):
process_file() {
file="$1"
}
2. Function Structure and Documentation
Use consistent formatting with comprehensive documentation:
Any function that is not both obvious and short must have a function header comment. All functions in libraries must have a function header comment regardless of length or complexity.
All function header comments must describe the intended API behavior using these required sections (always present, even if not applicable):
- Description: What the function does
- Globals: List of global variables used and modified
- Arguments: Arguments taken
- Outputs: Output to STDOUT or STDERR
- Returns: Returned values other than the default exit status
process_log_file() {
log_file="$1"
format="${2:-text}"
if [ ! -f "${log_file}" ]; then
echo "Error: Log file '${log_file}' not found" >&2
return 1
fi
if [ "${format}" != "text" ] && [ "${format}" != "json" ]; then
echo "Error: Invalid format '${format}'. Use 'text' or 'json'" >&2
return 2
fi
case "${format}" in
json)
grep \
| sed \
| -u \
| awk
;;
text)
grep \
| sed \
| -u
;;
0
}
() {
dm_message=
}
3. Variable Scope
Use function-specific variable prefixes (no local in POSIX):
Since POSIX shell doesn't have local, all function variables are global. Use consistent prefixes to avoid conflicts. Initialize function variables to sane defaults.
summarize_log() {
sl_log_file="$1"
sl_line_count=""
sl_line_count=$(wc -l < "${sl_log_file}")
echo "INFO: ${sl_log_file} contains ${sl_line_count} lines"
}
validate_input() {
vi_input="$1"
vi_format="${2:-text}"
}
my_package_parse_config() {
mppc_config_file="$1"
mppc_section="$2"
}
Naming Conventions
1. Variables
Use lowercase with underscores (snake_case):
user_name="john_doe"
config_file="/etc/myapp/config.conf"
temp_directory="/tmp/myapp_$$"
2. Constants and Environment Variables
Use uppercase with underscores:
readonly CONFIG_DIR="/etc/myapp"
readonly MAX_RETRIES=3
export LOG_LEVEL="INFO"
3. Loop Variables
Name descriptively:
for user_id in user1 user2 user3; do
process_user "${user_id}"
done
for i in user1 user2 user3; do
process_user "${i}"
done
Error Handling and Return Values
1. Check Return Values
Always check command return values.
Under set -eu, a bare failing command exits the shell before any following if [ $? -ne 0 ] check can run. Handle failures inline:
if ! cmd; then ...; fi when you only care about the failure path
if cmd; then ...; else ...; fi when success and failure both need work
if ! var=$(cmd); then ...; fi for the same pattern with command substitution
if ! mv "${source_file}" "${dest_dir}/"; then
echo "Error: Unable to move ${source_file} to ${dest_dir}" >&2
exit 1
fi
if sort "${input_file}" > "${temp_file}"; then
mv "${temp_file}" "${output_file}"
else
echo "Error: Failed to sort ${input_file}" >&2
rm -f "${temp_file}"
exit 1
fi
if ! disk_usage=$(du -sk "${backup_dir}"); then
echo "Error: Failed to check disk usage for ${backup_dir}" >&2
exit 1
fi
2. Pipeline Error Handling
Use intermediate variables/files for reliable pipelines when needed:
The verbose approach should only be used when pipeline reliability is critical. For simple cases, normal pipes are acceptable.
Use variables for safe content (no special shell characters):
if ! file_list=$(find "${dir}" -name "*.txt"); then
echo "Error: find command failed" >&2
exit 1
fi
echo "${file_list}" | while read -r file; do
process_file "${file}"
done
Use temporary files for unsafe content (quotes, backticks, dollar signs, etc):
temp_file=$(mktemp)
if grep "complex pattern with $variables" "${input_file}" > "${temp_file}"; then
while read -r line; do
echo "Found: ${line}"
done < "${temp_file}"
rm -f "${temp_file}"
else
echo "Error: grep command failed" >&2
rm -f "${temp_file}"
exit 1
fi
Simple pipelines can remain simple when reliability isn't critical:
ps aux | grep nginx | awk '{print $2}'
find /var/log -name "*.log" | head -10
3. Error Reporting
Send errors to STDERR with timestamps:
err() {
echo "[$(date '+%Y-%m-%dT%H:%M:%S%z')]: $*" >&2
}
if ! create_backup "${database}"; then
err "Failed to create backup for database: ${database}"
exit 1
fi
4. Returning Values
When returning status from a function, use numerical return codes:
is_even() {
if [ $(expr $1 % 2) -eq 0 ]; then
return 0
else
return 1
fi
}
file_exists() {
if [ -f "$1" ]; then
echo "true"
else
echo "false"
fi
}
When returning strings from a function with stdout, ensure that the function only ever returns the proper string:
mentions_cursor() {
git fetch --all >&2
if grep -q -lr "cursor"; then
echo "true"
else
echo "false"
fi
}
mentions_cursor() {
if grep -lr "cursor" . >/dev/null; then
echo "true"
else
echo "false"
fi
}
mentions_cursor() {
if grep -q -lr "cursor"; then
echo "true"
else
echo "false"
fi
}
mentions_cursor() {
if grep -lr "cursor"; then
echo "true"
else
echo "false"
fi
}
Feature Usage Guidelines
1. Command Substitution
Use $(...) instead of backticks:
current_date=$(date '+%Y-%m-%d')
file_count=$(find "${dir}" -type f | wc -l)
current_date=`date '+%Y-%m-%d'`
file_count=`find "${dir}" -type f | wc -l`
2. Test Constructs
Use [ ] or test for POSIX compatibility:
if [ "${file}" != "${file%.txt}" ]; then
echo "Text file detected"
fi
if [ -n "${variable}" ] && [ "${variable}" != "default" ]; then
process_variable "${variable}"
fi
if [ -z "${string}" ]; then
echo "String is empty"
fi
if [ "${string1}" = "${string2}" ]; then
echo "Strings are equal"
fi
if [ -f "${file}" ]; then
echo "File exists"
fi
if [ -d "${directory}" ]; then
echo
3. Arithmetic
Use expr for calculations and test for comparisons:
Calculations with expr (POSIX-compliant):
total=$(expr ${count} \* ${price})
i=$(expr ${i} + 1)
difference=$(expr ${end} - ${start})
remainder=$(expr ${number} % 10)
result=$(expr \( ${a} + ${b} \) \* ${c})
length=$(expr length "${string}")
substring=$(expr substr "${string}" 2 3)
Comparisons with test (using [):
if [ "${count}" -gt "${threshold}" ]; then
echo "Threshold exceeded"
fi
if [ "${result}" -eq 0 ]; then
echo "Success"
elif [ "${result}" -lt 0 ]; then
echo "Negative result"
else
echo "Positive result"
fi
4. Lists and Collections
Avoid arrays entirely - use alternative approaches:
POSIX shell has no arrays. Use these alternatives sparingly and only when necessary.
Positional parameters (limited use):
set -- replaces the current positional parameters: at script top level it destroys the script's own $@. Inside a function, positional parameters are local to the function and restored when it returns, so wrap temporary set -- work in a function. Do not save and restore via "$*" and unquoted set -- — that word-splits and glob-expands:
process_paths() {
set -- "/path/one" "/path/two" "/path/three"
for pp_file in "$@"; do
echo "Processing: ${pp_file}"
done
echo "Total files: $#"
pp_first_file="$1"
shift
}
process_paths
Space-separated strings (when safe):
file_list="file1.txt file2.txt file3.txt"
for file in ${file_list}; do
echo "Processing: ${file}"
done
Newline-separated processing:
find /path -name "*.txt" | while read -r file; do
echo "Processing: ${file}"
done
{
echo "item1"
echo "item2"
echo "item3"
} | while read -r item; do
echo "Processing: ${item}"
done
Main Function Pattern
Use main function for executable scripts with multiple functions:
#!/bin/sh
setup_environment() {
}
process_arguments() {
}
cleanup() {
}
main() {
setup_environment
process_arguments "$@"
cleanup
}
if [ "${0##*/}" = "script_name.sh" ]; then
main "$@"
fi
Security Considerations
1. Avoid SUID/SGID
Never use SUID/SGID on shell scripts:
if ! sudo systemctl restart nginx; then
echo "Error: Failed to restart nginx" >&2
exit 1
fi
2. Validate Inputs
Always validate and sanitize inputs:
validate_input() {
input="$1"
if [ -z "${input}" ]; then
echo "Error: Input required" >&2
return 1
fi
case "${input}" in
*[!a-zA-Z0-9_]*)
echo "Error: Invalid input format" >&2
return 1
;;
esac
return 0
}
Built-in Preferences
Prefer POSIX built-ins over external commands:
string_length=${#variable}
result=$(expr "${x}" + "${y}")
case "${string}" in
*pattern*)
echo "Pattern found"
;;
esac
Advanced Features
1. Wildcard Expansion
Be careful with filename expansion:
for file in /path/to/files/*.txt; do
[ -f "${file}" ] || continue
process_file "${file}"
done
set -f
echo "This * will not expand"
set +f
2. Working with Lists
Avoid list-like operations when possible:
process_files() {
find "$1" -name "*.txt" | while read -r file; do
echo "Processing: ${file}"
done
}
process_predefined_items() {
ppi_counter=0
process_item "item1"
ppi_counter=$(expr ${ppi_counter} + 1)
process_item "item2"
ppi_counter=$(expr ${ppi_counter} + 1)
process_item "item3"
ppi_counter=$(expr ${ppi_counter} + 1)
echo "Processed ${ppi_counter} items"
}
3. Here Documents
Use here documents for multi-line strings:
cat <<EOF
This is a multi-line
string that can contain
variable substitutions: ${variable}
EOF
cat <<'EOF'
This text is literal:
${variable} will not be expanded
EOF
Common Pitfalls to Avoid
- Don't use bash-specific features - Stick to POSIX
- Avoid complex parameter expansion - Use external tools when needed
- Don't ignore return values - Always check command success
- Avoid complex pipelines - Use intermediate files/variables
- Extraneous stdout in functions that return strings - redirect or discard all output except the return value
- Don't use arrays - Use positional parameters or space-separated lists
files=( file1.txt file2.txt file3.txt )
process_file_list() {
set -- file1.txt file2.txt file3.txt
for pfl_file in "$@"; do
process_file "${pfl_file}"
done
}
Testing and Validation
Write testable POSIX shell scripts:
#!/bin/sh
add() {
expr $1 + $2
}
subtract() {
expr $1 - $2
}
main() {
case "$1" in
add) add "$2" "$3" ;;
sub) subtract "$2" "$3" ;;
*) echo "Usage: $0 {add|sub} num1 num2" >&2; exit 1 ;;
esac
}
case "$0" in
*/calculator.sh|calculator.sh) main "$@" ;;
esac
Test with different shells:
dash myscript.sh
sh myscript.sh
ksh myscript.sh
ShellCheck Integration
Use ShellCheck with POSIX checking:
shellcheck --shell=sh myscript.sh
Common POSIX compliance fixes:
my_func() {
mf_my_var="value"
}
set -- one two three