TS-31: Unix Shells and POSIX Standards

This technical standard covers best practices for writing Unix shell scripts for the purposes of system administration, batch processing, and other low-level automation tasks.

See also TS-32: Bash for extended technical standards for the Bash shell specifically. See also TS-62: Make for guidance on Makefiles, which commonly wrap shell scripts and recipes covered by this standard.

Use cases

Shell scripting SHOULD be used only for small utilities and simple wrapper scripts – basic automation tasks, in other words.

If the script is mostly calling other utilities, and if it’s doing very little data manipulation or complex logic, then shell is probably a good technology choice for the problem.

However, if performance matters there will probably be more optimum technology choices. Likewise, if the logic includes any non-trivial control flows or if data integrity is paramount, then it may well be better to use a more structured programming language.

Portability

When writing shell scripts it is important to consider the target environment and the particular Unix shell(s) that will be available to execute the script in that environment.

If in doubt, Bash – the "Bourne Again Shell" – is a good choice. It is widely available: it is the most common shell implementation in Linux; it’s bundled with macOS (though it’s no longer the default shell there); and versions are even available for Windows (Git Bash, WSL, etc.). In addition, Bash has many customizations that make it particularly suitable for complex scripting – things like local variables.

But, if portability between different Unix shells is a requirement – for example, if you do not control the environments in which your scripts will be run – then it is best to stick to POSIX-compliant features and avoid shell-specific extensions.

Many Unix shells have a "POSIX mode" that allows you to test a script’s POSIX compliance by disabling the shell’s non-POSIX features when a script is run. For example, Bash can be run in POSIX-mode using the commands sh or bash --posix. Alternatively, the following shebang can be added to the top of scripts to trip Bash into running them in POSIX-mode:

#!/usr/bin/env sh

The following shebang will also tell Bash (and other shells) to run in POSIX-mode. However, this version is a little less portable, because it depends on the path to the sh executable being consistent across runtime environments. The version above will instead lookup the sh executable in a wider range of paths (as configured in the PATH environment variable).

#!/bin/sh

Scripts that use either of these shebangs MUST NOT contain Bashisms – like local variables – or other shell-specific syntax or built-ins. A linter MUST be used to identify POSIX compliance issues; ShellCheck IS RECOMMENDED for this purpose.

Tip

Dylan Araps’s Pure SH bible is an invaluable reference resource for writing POSIX-compliant shell scripts.

Built-ins versus external commands

Whatever the choice of target shell, prefer to write scripts that invoke the shell’s built-ins rather than external commands. For example, for simple text manipulation prefer to use the shell’s built-in parameter expansion functions rather than external commands like sed or awk.

Scripts that rely on external commands are less portable, because those commands may not be available in all deployment environments. Scripts that call external commands may be slower too, because external utility programs are executed as separate processes (whereas built-in commands are run in the shell’s process).

But often the use of external commands is unavoidable, especially for more complex tasks that require, for example, advanced text processing or system interaction. Where external commands are needed, prefer to use commands that are specified by the POSIX standard; these commands are (almost) guaranteed to be available in all Unix and Unix-like systems. Standard Unix utilities are listed in the POSIX Shell and Utilities specification.

Tip

You can check whether a command is a shell built-in or an external command by typing:

type <command>

Example:

$ type rm
rm is /bin/rm

This output shows that rm is an external command, not a shell built-in.

All other dependencies – where a "dependency" is anything that is not part of the target shell language – SHOULD be documented, either in a file-level comment at the top of the script, or in an accompanying README.

Options

It is RECOMMENDED to use set at the top of scripts to set shell options, so that calling the script using sh <script-name> does not break its functionality.

The following options are all POSIX-standard and work in any POSIX shell:

  • set -e: Exit on error (errexit)
  • set -u: Exit on undefined variable (nounset)
  • set -x: Print commands before executing (xtrace)
  • set -v: Print shell input lines as they’re read (verbose)
  • set -f: Disable pathname expansion (globbing)
  • set -n: Read commands but don’t execute (syntax check)
  • set -C: Prevent output redirection from overwriting files (noclobber)

Exit on error (errexit) with set -e is especially useful for improving the robustness of scripts. It prevents scripts from continuing to run after an error has occurred – ie. in the event that any command returns a non-zero exit status – which could otherwise lead to unintended side effects and cascading failures downstream. The downside is this setting makes it harder to handle errors gracefully within the script itself, and it can have some surprising behavior with pipelines and conditionals.

The set -x option MUST NOT be used in production scripts. It produces verbose output that is intended only for debugging. It MAY be enabled temporarily while debugging scripts.

Different shells support extended non-POSIX options. For example, Bash supports set -o pipefail, which causes scripts to exit when any command within a pipeline fails. Without this option, set -e would exit only if the last command in the pipeline fails. This Bash option tends to be used as an enhancement to the POSIX-compliant -e and -u options to run scripts in "strict mode":

#!/usr/bin/env sh
set -eu
POSIX sh
#!/usr/bin/env bash
set -euo pipefail
Bash

Execution and permissions

Scripts that are intended to be executed directly MUST be executable. This is achieved by setting the executable bit on the script file: chmod +x <script-name>.

Libraries – shell scripts that are intended only to be sourced by other scripts – SHOULD NOT be executable. This is achieved by removing the executable bit on the script file: chmod -x <script-name>.

SUID and SGID MUST NOT be applied to any shell scripts, whether or not those scripts are executed directly. These permissions open up potential vulnerabilities. Programs with SUID (Set User ID) are run with the elevated permissions of the file owner, so increasing risk if the scripts are exploitable. And SGID (Set Group ID), when applied to a file, executes the file with the group permissions of the file rather than the user’s current group. SGID is used to give group access to executables, but it can lead to unintended access if those groups are not managed carefully.

# In the below output, the `s` in the `rws` bit shows that
# SUID is set on the user (owner) permission.
$ ls -l /usr/bin/passwd
-rwsr-xr-x 1 root root ...

# In the below output, the `s` in the group section (`rws`)
# shows that SGID is set on the directory.
$ ls -ld shared_folder
drwxrwsr-x 2 alice devteam ...

Use the following commands to unset SUID and SGID from shell scripts:

# Remove SUID.
chmod u-s filename

# Remove SGID.
chmod g-s filename

Downloading and executing remote scripts

Remote scripts MUST NOT be piped directly into a shell for execution, and this practice is especially dangerous when combined with sudo:

# ❌ Executes unreviewed remote code, with root privileges, with no
# opportunity for inspection and no local record of what was run.
curl -sL https://example.com/install.sh | sudo -E bash -

This pattern is a common convenience shortcut in install instructions because it avoids the small number of steps a user could get wrong when downloading and running a script manually. But those same steps are what allow the script to be verified before it runs. Specific risks include:

  • Man-in-the-middle tampering. If the script is served over plain HTTP, a network attacker can substitute their own content in transit. HTTPS mitigates this by giving some confidence that what was downloaded is what the server actually sent, but URLs in install instructions SHOULD always be checked to confirm they use https://, not http://.
  • Disabled certificate validation. Some install instructions work around TLS certificate errors by telling users to pass flags such as curl -k or wget --no-check-certificate. This disables the very check that HTTPS provides, re-opening the door to a machine-in-the-middle attack even though the URL uses https://. Certificate errors MUST be resolved properly – for example, by fixing the system’s trusted CA store – never suppressed.
  • Partial execution. If the connection is interrupted mid-stream, the shell may still execute the commands received so far. A truncated script can produce a syntactically valid but unintended and potentially dangerous sequence of commands, rather than simply failing.
  • Server-side content negotiation. Opening the script’s URL in a browser to eyeball it first does not reliably mitigate the above risks. A malicious or compromised server can serve different content based on the request’s User-Agent header, showing a browser a benign script while curl (or a pipeline) receives something else entirely.

Piping to sudo compounds all of the above, since the arbitrary code then runs with root privileges.

Download the script to disk, inspect it, and only then execute it:

# ✅
curl -sL https://example.com/install.sh -o install.sh
less install.sh          # Review before running.
sudo -E bash install.sh

This also leaves a local artifact that can be diffed against future versions of the script, or checked into version control.

General code style

For legacy code, stay faithful to the existing prevailing code conventions. Else follow the guidelines below for new shell scripts.

Source order

The source code for shell scripts SHOULD follow this order:

  1. Shebang and set operations
  2. File-level comments
  3. Variables
  4. Functions
  5. Main program

Keep all custom functions together in one block. Try to avoid hiding executable code between functions. It makes the code hard to follow.

This structure also provides a useful framework for decomposing large scripts into smaller, more manageable files.

#!/usr/bin/env sh

# File description here.
# Copyright: <Legal Name>
# License: MIT

. variables.sh
. functions.sh

main "$@"

For libraries – ie. shell scripts that are intended only for sourcing into other scripts, rather than direct execution – the shebang line is optional but RECOMMENDED. Including the shebang in all shell scripts, including sourced ones, provides clarity over which shell the script targets for compatibility.

main()

For complex scripts – anything more than a couple of hundred lines, or anything with non-linear control flows – it is RECOMMENDED to define a function called main that will be the main entry point for the program. This SHOULD be the first function defined, but it should be called last, at the very end of the script.

Thus, the very last line in a shell script SHOULD be a call to the main function. Arguments passed to the script SHOULD, normally, be forwarded to the main function.

main "$@"

Indentation

Use two spaces. Never use tabs for indentation.

The only exception for use of tabs is in tab-indented here-documents, ie. in the body of <←.

Blank lines

Insert blank lines between discrete blocks of code, to improve readability.

Line length

Most code lines – except literal strings that can’t be wrapped – SHOULD be kept under 80 characters in length.

It is RECOMMENDED to use continuation lines to break up long commands, expressions, and other statements that would otherwise exceed the line length limit.

command1 \
    && command2 \
    && command3

Continuation lines MUST be indented to show that they are continuations of the preceding line. It is RECOMMENDED to use double indentation – four spaces – for continuation lines.

Expressions SHOULD be broken before, not after.

Pipelines are another good use case for continuation lines. Put the pipe symbol followed by the next command in the chain on a new line. Comments will need to precede the whole pipeline. If the pipeline is complex, and individual commands within it require extensive explanation, extract those commands into separate functions and use function-level comments to capture the information.

# Comment for the whole pipeline.
command1 \
    | command2 \
    | command3 \
    | command4

Code lines MAY be longer than 80 characters where breaking the line decreases readability.

Naming conventions

General guidelines for naming things

As per TS-7: Code Design, err on the side of clarity over brevity in the naming of things. Do not truncate or abbreviate the names of things where doing so would decrease understandability of the code.

The names of all things – functions, variables, etc. – SHOULD be descriptive in the places in which those things are used, not only in the places where they are defined. This means you can’t rely on adjacent comments to document the meaning of things where they are declared, because those names will appear in other code contexts where those descriptions are not present.

File names

Shell scripts SHOULD be named with all-lowercase ASCII letters, with words delimited by hyphens.

The .sh extension MAY be omitted for files that are intended to be executed like binaries. The .sh extension SHOULD be kept for shell libraries – ie. files that are intended to be sourced by other shell scripts, or be executed by other build tools such as make.

Thus, the omission of the .sh extension informs users that the script is intended to be directly executed as a command: ./<script-name> <arg1> <arg2> …​.

Variable names

The names of variables should be composed from lowercase ASCII letters only, with underscores used to delimit words. Numbers (0-9) MAY be used in variable names in appropriate scenarios.

A common convention is to use UPPER_SNAKE_CASE for variable names. This is bad practice. Using this naming convention risks collisions with shell-defined variables and environment variables.

Constants – which are declared with the readonly keyword – also SHOULD NOT be capitalized. This too is a common convention, but it is bad practice for the same reason.

# ❌
readonly PATH_TO_FILES='/some/path'

# ✅
readonly path_to_files='/some/path'

The only exception to this naming convention is for variables exported to the environment – ie. environment variables that will be made available to all child processes spawned from the current shell. These SHOULD be capitalized, following the prevailing conventions for Unix environment variables. Environment variables are, after all, intended for use by other scripts and programs, so it is best to stick with the community’s naming conventions here.

export PATH="/usr/local/bin:$PATH"
Examples

For the sake of clarity, use of readonly and export are RECOMMENDED over the use of declare commands. Even if you want to export a constant, be explicit and use separate readonly and export statements, rather than combine them into a single declare command.

# ✅
readonly ORACLE_SID='PROD'
export ORACLE_SID

# ❌
declare -xr ORACLE_SID='PROD'

Consider using a vendor-specific prefix for all the variables your scripts export to the environment. This helps to reduce the likelihood of collisions with environment variables set by other scripts and programs, or even by the shell itself.

Function names

Functions SHOULD follow the same naming convention as for variables; that is, function names SHOULD be composed from lowercase ASCII letters with underscores used to delimit words.

Functions that are part of the public interface of a package SHOULD be namespaced. It is RECOMMENDED to use the following naming convention for this purpose.

<package_name>::<function_name>
my_pkg::my_func() {
  # ...
}
Example

Variables

Most variables in a script SHOULD be designed to be constants, which means their values SHOULD NOT change after being assigned the first time. Err on the side of writing new variables, rather than overwriting existing ones, whenever you need to store a new value in memory. Scripts that follow this design principle tend to be a bit more robust and easier to understand and debug.

Variables MUST be declared readonly unless they are required to be writable by the business logic. This improves the robustness of scripts by preventing the overwriting/reassignment of variables that are not intended to be changed. The readonly attribute SHOULD be applied immediately after the variable declaration. Alternatively, use declare -r to declare a variable and set its readonly attribute immediately.

zip_version=$(dpkg --status zip | grep 'Version:' | cut -d ' ' -f 2)

if [ -z "${zip_version}" ]; then
  # Error handling here.
  exit "${error_code}"
else
  readonly zip_version
fi

Bracket syntax

Most variable references SHOULD use the bracketed syntax, ${var}, over the unbracketed one, $var. The bracketed syntax is more readable, more robust, and more flexible. Because the brackets clearly delimit the variable name, it is easier to identify the variable names, and it helps to avoid ambiguity in complex expressions. It also makes it easier to concatenate with other variables or literal string values, eg. ${var}bar.

var="foo"

# Looks for a variable named 'varbar' (likely undefined).
echo "$varbar"

# Correctly expands to 'foobar'.
echo "${var}bar"

In addition, the bracketed syntax can be extended to query and manipulate values returned from variable substitution. For example, ${#var} returns the length of a string value, ${var:0:1} returns the first character of the value, and so on. It is also possible to provide default (fallback) values.

However, the brackets MAY be omitted from positional parameters – $1, $@, etc. – and other special variables.

Quotes and variable expansion

For variable assignment, almost all values SHOULD be quoted. There are some exceptions:

# Quote most values on assignment for consistency, even if not required.
flag="on"

# Literal integers that will be used in mathematical expressions MAY be unquoted.
val=42

# Quote command substitutions, even when you expect the output to
# be an integer. Use single quotes for literal arguments passed to
# the command.
result="$(some_command 'arg1' 'arg2')"

# The following two statements are equivalent. In both cases, the value `true`
# is a string. Shell scripts do not have a boolean type, but it is convention to
# use the string values "true" and "false" to represent boolean values. However,
# this may not be obvious to novice shell programmers, so better to be explicit
# and include the quotes.
bool="true" # ✅
bool=true   # ❌

Variable references SHOULD be quoted in almost all cases, even if the values are things like commands or path names. This prevents word splitting and globbing issues.

Double quotes SHOULD be used in almost all cases. Single quotes MUST be used only where you explicitly want to disable substitution.

# ❌ SHOULD NOT do this for string values, unless variable expansion is intended:
echo ${var}

# ✅ RECOMMENDED in almost all cases:
echo "${var}"

The risk of not quoting variables is demonstrated by the following code example.

filename="My File.txt"

rm ${filename}    # Interpreted as: `rm My File.txt`   → error
rm "${filename}"  # Interpreted as: `rm "My File.txt"` → correct

Where variable expansion is required, the variable reference MUST NOT be quoted, and an adjacent comment MUST explain why the variable is being allowed to expand.

# Expand $vars into arguments.
some_command ${vars}

Positional parameters

Positional parameters are the arguments passed to a script or function. They are accessed using the $1, $2, etc. syntax.

It is RECOMMENDED to provide default values for positional parameters in most use cases.

When you want to pass on all parameters, say from the script to a main() function, you probably want to use "$@" (quoted). This will forward all arguments as-is. By comparison, both $@ and $ (unquoted) will split on spaces, clobbering arguments that contain spaces and dropping empty-string arguments. "$" (quoted) is probably not what you want either; it will expand to just one string argument, with words in the value concatenated by spaces.

Important

Always validate user input variables. This rule applies equally to input to scripts and input to functions within a script. Be defensive in all your code.

Argument parsing

Choose an argument-handling pattern based on the complexity of the script’s interface.

No-argument scripts SHOULD validate and reject unexpected input:

if [ $# -gt 0 ]; then
  printf "Error: script does not accept arguments\n" >&2
  exit 1
fi

Single-option scripts MAY use a simple case statement:

case "${1:-}" in
  --help)  show_help; exit 0 ;;
  -*)      printf "Error: unknown option '%s'\n" "$1" >&2; exit 1 ;;
  *)       : ;;
esac

Multi-option scripts SHOULD use a while loop with a case statement:

while [ $# -gt 0 ]; do
  case "$1" in
    --name)  name="$2"; shift 2 ;;
    --file)  file="$2"; shift 2 ;;
    -*)      printf "Error: unknown option '%s'\n" "$1" >&2; exit 1 ;;
    *)       break ;;
  esac
done

For scripts with more complex option requirements, consider getopts, which is POSIX-standard and handles short options (-f, -v) and combined flags (-fv).

Command substitution

Prefer the newer syntax, var=$(command), over the older backtick syntax, var=`command`. The reason is that nested backticks require escaping with \, reducing readability of the command statement.

# ✅
var="$(command "$(command1)")"

# ❌
var="`command \`command1\``"

However, both work in all modern POSIX-compliant shells. It is okay to maintain the older syntax in legacy scripts.

Functions

Function declarations

For POSIX-compliant scripts, the function keyword cannot be used in function declarations.

# ✅
my_func() {
  # ...
}

# ❌
function my_func() {
  # ...
}

It is RECOMMENDED to exclude the function keyword even where it is supported by the target shell. It does not add any value, only clutter.

Parentheses MUST be on the same line as the function name, with no space between the function name and the opening parentheses, and with no space between the opening and closing parentheses.

The opening curly brace SHOULD also be on the declaration line, preceded by a single space character. The closing curly brace SHOULD be on a new line at the same level of indentation as the opening of the function declaration.

Arguments over globals

Functions SHOULD receive the data they operate on as arguments (positional parameters), rather than reading it from global variables. A function that depends only on its arguments is reusable and predictable: its behavior does not change based on state set elsewhere in the script, and it can be reasoned about, tested, and moved to another script without modification.

# ❌ Depends on a global. Behavior silently changes if `target_dir` changes
# elsewhere in the script.
create_backup() {
  mkdir -p "${target_dir}/backup"
}

# ✅ Self-contained. Behavior depends only on the argument passed in.
create_backup() {
  dir="$1"
  mkdir -p "${dir}/backup"
}

Assign positional parameters to descriptively-named variables at the top of the function body, rather than referring to $1, $2, etc. throughout. This documents what each argument represents and makes the function body easier to read.

Local variables

Shells that support it – Bash and most other modern shells, though this is not POSIX – provide a local keyword for scoping variables to the function they are declared in. Use local for all variables that are internal to a function’s implementation, unless the variable is intentionally being used to return a value to the caller (see below). This prevents function-internal variables from polluting the global namespace, and prevents accidental overwriting of global variables that happen to share the same name.

count=1

increment() {
  local count  # Shadows the outer `count`; does not modify it.
  count=$(( $1 + 1 ))
  echo "${count}"
}

echo "$(increment 5)" # → 6
echo "${count}"        # → 1 (unchanged)

For scripts that must remain strictly POSIX-compliant, and therefore cannot rely on local, prefer passing values through arguments and return values (see below) rather than through shared global state, to minimize the risk of name collisions.

Command/query separation

Prefer writing functions that either perform an action (a "command") or compute and return a value (a "query"), but not both. A function that mutates state and hands back a result is harder to reuse and to reason about, because callers cannot invoke it for one purpose without triggering the other.

# ❌ Mixes concerns: computes a name AND creates the directory.
create_temp_dir() {
  dir="/tmp/$(date +%s)"
  mkdir -p "${dir}"
  echo "${dir}"
}

# ✅ Split into a query and a command. Each can be used, tested, or replaced
# independently.
temp_dir_name() {
  echo "/tmp/$(date +%s)"
}

create_dir() {
  mkdir -p "$1"
}

Return values

The return built-in only accepts an integer between 0 and 255, so it is suitable for exit-code-style results (see the Exit codes section) but not for returning strings or other data.

To return non-integer data from a function, have the function write the value to stdout, and have the caller capture it using command substitution, $( … ). This mirrors the return semantics of higher-level programming languages, and is the RECOMMENDED approach for most cases:

temp_dir_name() {
  echo "/tmp/$(date +%s)"
}

dir="$(temp_dir_name)"

Because command substitution runs the function in a subshell, this approach has some overhead, and any variables set inside the function (other than via echo) do not persist to the caller. For performance-sensitive code, or where a function needs to set a variable in the caller’s scope directly, see the nameref technique in TS-32: Bash → Functions.

Aliases

Although commonly seen in .bashrc files to customize a user’s environments, aliases SHOULD NOT be defined in scripts.

Functions SHOULD be preferred over aliases for almost every use case in shell scripting.

Control flow

It is RECOMMENDED to use the syntax in which ; then and ; do are written on the end of if/for/while/until/select statements, rather than on new lines. else SHOULD be on a line on its own. Closing statements (fi and done) SHOULD also be on their own lines, vertically-aligned with the opening statement. This style is the prevailing convention, and it is the most readable.

count=99

# ✅
if [ "${count}" -eq 100 ]; then
  echo "Count is 100"
elif [ "${count}" -gt 100 ]; then
  echo "Count is greater than 100"
else
  echo "Count is less than 100"
fi

# ❌
if [ "${count}" -eq 100 ]
then
  echo "Count is 100"
elif [ "${count}" -gt 100 ]
then
  echo "Count is greater than 100"
else
  echo "Count is less than 100"
fi

The inner content of block-level structures, such as conditionals and loops, MUST be indented by two spaces.

The whitespace padding within the square brackets is optional, but including it is RECOMMENDED. It is a common coding convention, and it further improves readability.

Conditional blocks may be nested, but this reduces readability and maintainability. Look to refactor complex conditional logic into the flattest possible structure. The && and || operators are useful tools here; they can be used to create shorthand conditional statements, executing commands based on the result of preceding commands.

if sudo apt-get update ; then
  sudo apt-get install pyrenamer
fi

# Can be refactored to:
sudo apt-get update && sudo apt-get install pyrenamer

Aim for there to be just one level of if/else conditions. If you needed nested conditions, consider using the shorthand syntax (&& and || operators), extracting nested logic into functions, or refactoring in other ways.

Case statements

Case statements SHOULD be written out as below. The pattern and closing ;; are each on their own lines at the same indentation level. Nested commands, run when the pattern matches, should be indented one additional level.

case expression in
  case1)
    operation1
  ;;
  case2)
    operation2
    operation3
  ;;
esac

However, simple commands may be put on the same line as the pattern and the ;;, as long as the expression remains readable. Add a space after the closing parenthesis of the pattern and another before the ;;. This is often appropriate for single-letter option processing.

verbose='false'
aflag=''
bflag=''
files=''

while getopts 'abf:v' flag; do
  case "${flag}" in
    a) aflag='true' ;;
    b) bflag='true' ;;
    f) files="${OPTARG}" ;;
    v) verbose='true' ;;
    *) error "Unexpected option ${flag}" ;;
  esac
done
Example from Google’s Shell Guide

In general, there is no need to quote match expressions. Pattern expressions SHOULD NOT be preceded by an open parenthesis.

Avoid the ;& and ;;& notations.

Subshells vs. blocks

Parentheses ( … ) and curly braces { … ; } both group commands, but they have different scoping semantics. Parentheses run the enclosed commands in a subshell – a forked child process with its own copy of the shell’s variables and current directory. Curly braces run the commands in the current shell, so variable assignments, cd, and other state changes made inside them persist after the block completes.

count=0

# ❌ Subshell: the increment is lost once the subshell exits.
( count=$(( count + 1 )) )
echo "${count}" # → 0

# ✅ Block: the increment persists in the current shell.
{ count=$(( count + 1 )); }
echo "${count}" # → 1

Use a subshell deliberately when isolation is what you want – for example, to cd into a directory and run commands without affecting the calling script’s working directory. Use a block when the enclosed commands need to affect the surrounding shell’s state, such as when grouping commands for a shared redirection or a shared &&/|| chain.

Note the syntactic difference: curly braces require a space after the opening brace and a semicolon (or newline) before the closing brace; parentheses do not.

Exit codes

All executable scripts MUST return an exit code. Functions MAY return exit codes, too.

Exit codes are limited to integers between 0 and 255.

Return 0 to represent success. Any non-zero value denotes an error. Use 1 for a general, undocumented error. Use other custom error codes for each handled scenario.

All error codes MUST be documented. They are an important part of the API of a script or function.

In scripts, check the return values from error-prone commands before continuing with the next operation. If the return value is unexpected, exit with a custom error code to represent that specific error condition. This is how exceptions are handled in shell scripts.

Add defensive checks before any operation that modifies files, deletes paths, or overwrites data. Common pre-condition patterns include checking for required tools and verifying file existence:

# Verify a required external tool is available.
if ! command -v jq >/dev/null 2>&1; then
  printf "Error: 'jq' is required but not installed\n" >&2
  exit 1
fi

# Verify a file exists before operating on it.
if [ ! -f "${target_file}" ]; then
  printf "Error: file not found: %s\n" "${target_file}" >&2
  exit 1
fi

# Dry-run a command before committing to it.
if ! some_command >/dev/null 2>&1; then
  printf "Error: precondition check failed\n" >&2
  exit 1
fi

Output

Standard output

Reserve stdout for script output — the data the caller or user expects to consume, capture, or pipe to another command. Status updates, progress messages, warnings, and errors MUST be directed to stderr instead:

# Data output: written to stdout for the caller.
echo "result: ${value}"

# Status and error messages: written to stderr.
printf "Processing file: %s\n" "${file}" >&2

This separation allows scripts to be composed in pipelines without status noise contaminating the output stream, and makes it possible for callers to capture output cleanly.

Error messages

OPTIONALLY, non-zero exit codes may be accompanied by user-friendly error messages to aid in debugging and user feedback. Error messages MUST BE directed to stderr, to keep actual issues separated from normal output.

Error messages SHOULD be prefixed with the name of the script or function that produced the error, to make it easier to identify the source of the error.

It is RECOMMENDED to implement a custom function to standardize error message formatting. It is RECOMMENDED to redirect the printed output to stderr using >&2. Add any other information to error messages that may help with debugging.

err() {
  echo "[$(date +'%Y-%m-%dT%H:%M:%S%z')]: $*" >&2
}

if ! do_something; then
  err "Unable to do_something"
  exit 1
fi
Example from Google’s Shell Guide

Comments

In higher-level programming languages, the higher abstractions allow programmers to express their design through modules, function and object names, data structures, and other constructs. Instead of relying on comments to explain the code, it is considered best practice to try to design the code in a way that it clearly articulates what it does, without additional annotations. Therefore, in higher-level programming languages, inline comments tend to be used quite sparingly, used to explain only the most complex algorithms, or why certain design patterns where chosen over more obvious ones, and so on.

Lower-level languages, like shells and other scripting languages, provide fewer opportunities to develop self-explanatory code. Indeed, the syntax of lower-level languages can often be cryptic and non-intuitive.

For this reason, it is strongly RECOMMENDED that shell scripts be liberally commented. Use comments to express in plain English things that are not obvious from the code itself. This is especially important for complex logic, unusual syntax, and other non-obvious constructs.

For shell scripts, it is okay for comments to describe what the code does. This is especially useful to draw attention to the most important bits of business logic. Even for experienced shell programmers, it will often be quicker to read through the comments, rather than read through the code itself, to understand what the code does and how it works.

Remember, the purpose of comments is to reduce cognitive overhead. Whatever the language or level of abstraction, add comments where they make things easier to understand, or where you want to communicate important information that cannot be ascertained from the code alone. Remove comments that are superfluous, redundant, or that do not add any tangible value.

If in doubt: leave a comment!

File-level comments

All shell files SHOULD start – after the shebang and any set options – with one or more contiguous comment lines that provide an overview of the contents and purpose of the script.

The format of this file-level comment block SHOULD be based on the template below, but may vary between projects as appropriate.

For publicly-distributed libraries, or components thereof, it is RECOMMENDED to include copyright, license, and support notices. For scripts that are intended to be executed directly it is RECOMMENDED to include brief usage documentation. And for all shell scripts it is RECOMMENDED to list all dependencies – any commands or programs that the script calls but that are not built-in to the shell.

#!/usr/bin/env sh

set -eu

#
# File description here.
#
# Copyright: 2025 John Doe
# License: MIT
# Support: https://github.com/team/project/issues
#
# Usage:
#   ./backup.sh [source] [destination]
#
# Dependencies:
# - <dep1>
# - <dep2>
# - <dep3>
#

Section comments

Longer scripts SHOULD be divided into clearly delimited sections, so a reader can scan the file and grasp its structure at a glance. A consistent hierarchy of banner-style comment delimiters makes the structure visible without reading every line.

Two levels of section delimiter are RECOMMENDED:

  • Major sections — delimited by a banner of equals signs. Use these for the top-level divisions of a script: the main entry point, globals, function definitions, and the main program body.
# ==============================================================================
# Section Title
# ==============================================================================
  • Subsections — delimited by a banner of hyphens. Use these for groups within a major section.
# ------------------------------------------------------------------------------
# Subsection Title
# ------------------------------------------------------------------------------

The banner lines SHOULD be 80 characters wide (a followed by 78 delimiter characters), matching the conventional line length. The title line sits between the two banners, prefixed with a single and a space.

A section banner SHOULD be followed by one or more ordinary comment lines that summarize the section’s purpose, before the code it introduces. Short sections MAY omit the summary if the title is self-explanatory.

Ordinary single-line comments — explanations of individual statements or small groups of statements — use a plain # with no banner, as elsewhere in the script.

#!/bin/sh

set -eu

# ==============================================================================
# Main entry point for the environment bootstrapper.
# ==============================================================================

# Exit on any error.
set -e

# ------------------------------------------------------------------------------
# Globals.
# ------------------------------------------------------------------------------

# Absolute path to this repository.
repo_root=$(dirname "$(dirname "$(readlink -f "$0")")")

# ------------------------------------------------------------------------------
# Load utility functions.
# ------------------------------------------------------------------------------

source "${repo_root}/lib/fn/statuses.sh"
source "${repo_root}/lib/fn/steps.sh"

Section banners are a visual aid, not a substitute for structure. A script that needs more than two levels of banners is probably too long and SHOULD be split into separate files.

Function comments

All functions SHOULD be commented - regardless of their length and complexity.

Function comments SHOULD contain:

  • A description of the function.
  • A list of global variables used (whether or not they are created or modified).
  • Arguments taken.
  • Outputs to stdout and stderr.
  • Returned values (ie. exit statuses).

The purpose of function comments is to make it easier for other programmers to use your functions. They should be able to do this by reading simple API documentation, written in English in a consistent structured format, rather than needing to reverse engineer the code in their heads.

# function_name - <Function description.>
#
# <Optional longer description.>
#
# Usage:
#   $ my_func <arg1> <arg2> ...
#
# Globals:
#   $<VAR> - <Description of the global variable.>
#   $<VAR> - <Description of the global variable.>
#
# Arguments:
#   $1 - <Parameter description.>
#   $2 - <Parameter description.>
#
# Output:
#   stdout - <Description of the output to stdout.>
#   stderr - <Description of the output to stderr.>
#
# Returns:
#   1 - <Description required for non-void and non-zero return values (error conditions).>
#
my_func() {
  # ...
}
Template
# print_success - Print notification of a successful operation.
#
# Globals:
#   $BOLD - ANSI escape code for bold text.
#   $GREEN - ANSI escape code for green text.
#   $RESET - ANSI escape code to reset text formatting.
#
# Arguments:
#   $1 - Message to print.
#
# Output:
#   stdout - Formatted success message.
#
print_success() {
  printf '%s%s[SUCCESS]%s %s\n' "${BOLD}" "${GREEN}" "${RESET}" "$1"
}
Example

The function description – at the top of a function’s comment block – MUST be clear about any side effects of calling the function that might take a programmer by surprise. Examples of side effects that SHOULD be documented include:

  • Changes to the current working directory.
  • Changes to the filesystem (eg. create directories, move files, etc.).
  • Exiting the process (exit).

However, although printing output to stdout and stderr is a class of side effect, this is normal behavior for shell functions, so you don’t need to draw the user’s attention to it. Documenting this behavior in the Outputs: block is adequate.

TODOs

TODO comments MAY be included in shell scripts to draw attention to areas that require further development, review, or refactoring.

Use the following convention for TODO comments.

# TODO: Short description. [#34]

The square brackets on the end are OPTIONAL; they reference an issue number in the project’s task tracker, if applicable.

If you intend to implement the fix or improvement yourself, you MAY include your unique identifier in brackets adjoining the TODO keyword, as below. Use a consistent identifier, so you can easily search for your personal TODOs across a codebase. Your unique identifier may be your email address, GitHub username, etc.

# TODO(username): Short description. [#34]

Tip

IDE extensions such as Todo Tree can help you to manage code TODOs.

Miscellaneous shell built-ins

This section covers best practices for using various other shell built-ins. Examples of shell built-ins include echo, read, set, and eval.

echo

Do not use echo -e. The -e flag — which enables interpretation of backslash escape sequences such as \n and \t — is not POSIX. Its behaviour is implementation-defined and varies across shells and platforms.

Use printf instead for any output that requires escape interpretation:

# ❌
echo -e "Done.\nSee the log for details."

# ✅
printf "Done.\nSee the log for details.\n"

Plain echo (without -e) is fine for simple string output with no escape sequences.

eval

Do not use eval. It munges the input when used for assignment to variables, and it can set variables without making it possible to check what those variables were.

# What does this set? Did the command `set_my_variables` succeed?
# The script itself cannot answer these questions.
eval $(set_my_variables)

Unix utilities

This section covers best practices for using standard Unix utilities, which are implemented as external commands rather than being built-in to the shell itself, but which are commonly used in shell scripts. Examples of external Unix utilities include rm, ls, and grep.

rm

Be careful about using wildcard expansion of filenames. Consider a directory with the following contents:

-f (file)
-r (file)
somedir (dir)
somefile (file)

Notice that we have files here named -f and -r. If you use * to expand the filepaths, like this:

# ❌
rm -v *

The glob expands to bare names -f, -r, somedir, somefile, which the shell passes directly to rm. The -f and -r are interpreted as flags, causing rm to silently force-delete recursively.

The fix is to use -- to signal the end of options, so all remaining arguments are treated as filenames:

# ✅
rm -v -- *

Alternatively, prefix the glob with ./ so expanded names are paths and cannot be parsed as flags:

# ✅
rm -v ./*

Note that neither form will remove the subdirectory somedirrm requires -r to delete directories.


References