Skip to content
Shell & TerminalHow-To Published Updated 5 min readViews unavailable

How to Write Robust, Portable POSIX Shell Scripts

Writing shell scripts that run correctly under any POSIX-compliant shell, not just whichever one happens to be installed on your own machine.

A script written and tested only under Bash can fail — sometimes silently, producing wrong results rather than an obvious error — the first time it actually runs under a strict POSIX shell like Dash. This walks through writing scripts that are genuinely portable from the start.

Step 1: use the correct shebang and mean it

#!/bin/sh

If your script is genuinely intended to be POSIX-portable, the shebang should say so — and every line inside the script needs to actually honor that promise, not just the shebang itself.

Step 2: avoid Bash-specific array syntax entirely

# Bash-only, will break under strict POSIX sh:
arr=(one two three)
echo "${arr[0]}"

# POSIX-portable alternative: use positional parameters
set -- one two three
echo "$1"

POSIX sh has no native array type at all — positional parameters ($1, $2, "$@") are the portable substitute for many common array use cases.

Step 3: avoid [[ ]] extended test syntax

# Bash/Zsh-only:
if [[ "$var" == "value" ]]; then

# POSIX-portable:
if [ "$var" = "value" ]; then

The double-bracket [[ ]] syntax is a Bash and Zsh extension, not part of POSIX — the single-bracket [ ] form (actually the test command) is the portable equivalent, with slightly different, stricter syntax rules of its own.

Step 4: always quote variable expansions

[ -n "$var" ]     # correct
[ -n $var ]       # breaks if $var is unset or contains spaces

This single habit prevents the majority of real-world shell scripting bugs, and it matters equally whether you’re targeting Bash specifically or writing for strict POSIX portability.

Step 5: use command substitution’s modern syntax, which is itself POSIX-compliant

result=$(some-command)    # POSIX-compliant, use this
result=`some-command`     # older, harder to nest and read

$(...) is both the more readable option and fully POSIX-compliant — there’s no portability reason to prefer the older backtick syntax.

Step 6: check your script with shellcheck

shellcheck --shell=sh myscript.sh

Explicitly specifying --shell=sh tells ShellCheck to flag Bash-specific constructs that would break under strict POSIX sh, rather than assuming Bash-compatible syntax is acceptable.

Step 7: test your script against an actual strict POSIX shell, not just Bash

dash myscript.sh

Testing against Dash specifically (rather than only Bash, even when the shebang says #!/bin/sh) is a useful way to expose common Bash dependencies — many systems’ /bin/sh is Dash or another smaller shell, and Bash itself will happily run non-portable syntax without complaint. One implementation cannot prove universal portability, so a production test matrix should include the actual shells and utility implementations used by the target systems.

Step 8: avoid other common Bash-specific features

- process substitution: <(command) and >(command)
- string manipulation operators like ${var,,} (lowercase)
- the "local" keyword's exact scoping behavior (varies)
- here-strings: command <<< "text"

Each of these is convenient in Bash but either unavailable or behaves differently under strict POSIX shells — worth specifically checking for if you’re auditing an existing script for genuine portability.

Why portability matters even if you personally always have Bash available

Container base images, minimal CI environments, embedded systems, and many production servers deliberately use a minimal /bin/sh for faster script execution — as Ubuntu’s 2006 switch to Dash demonstrated at ecosystem scale, a script that only works under Bash can fail unpredictably the moment it’s deployed somewhere that doesn’t guarantee Bash’s presence, regardless of how reliably it worked on the machine where it was written and tested.

What “POSIX” actually refers to as a standard

POSIX itself dates to 1988, when the IEEE ratified the first version as IEEE Std 1003.1-1988, drawing on established Unix interfaces. The shell and utility work was historically published separately as POSIX.2 and standardized a Bourne-shell-derived command language — which is why “POSIX-portable” scripting means targeting the standard’s defined shell language rather than assuming every convenience added by Bash, Zsh, or Ksh.

The current edition is POSIX.1-2024, published jointly as IEEE Std 1003.1-2024 and The Open Group Base Specifications, Issue 8. Issue 8 preserves a large portable core while adding and clarifying interfaces; it does not make every historical /bin/sh implement the new edition immediately. State the edition a requirement targets, consult that edition’s utility pages, and test on the oldest implementations actually supported rather than treating “POSIX” as a versionless guarantee.

Step 9: never rely on echo -e for escape sequences

echo -e "line1\nline2"     # prints the literal text "-e line1\nline2" under Dash
printf 'line1\nline2\n'    # portable for this fixed format string

When echo receives a first operand of -n or any operand containing a backslash, POSIX leaves the result implementation-defined. Bash can interpret -e on request, while Dash may print it as literal text, producing different output from the same script. POSIX printf specifies its format language and is the portable choice for controlled escapes; replacing echo -e with an equivalent printf avoids depending on that implementation-defined branch.

What “POSIX-compliant” doesn’t guarantee on its own

Passing shellcheck --shell=sh and running cleanly under dash confirms syntactic and largely semantic portability across POSIX-compliant shells, but it says nothing about the external commands a script invokes — grep, sed, awk, and date all have real behavioral differences between GNU and BSD implementations that POSIX itself only partially constrains, and those differences surface exactly the same way syntax differences do: silently, on whichever system doesn’t match the one the script was written and tested on. Portable shell scripting is necessary but not sufficient for a script that needs to run identically everywhere; the utilities it shells out to need the same scrutiny.

Related:

Sources:

Comments