How to Write Robust, Portable POSIX Shell Scripts
A complete walkthrough writing shell scripts that run correctly under any POSIX-compliant shell — not just whichever one happens to be installed on your own development 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 the only way to genuinely confirm portability — many systems’ /bin/sh is Dash or similarly strict, and Bash itself will happily run non-portable syntax without complaint.
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.