How to Write Portable POSIX Shell Scripts
Write shell scripts that run correctly under dash, BusyBox sh, and Bash by avoiding Bashisms, using POSIX constructs, and linting with shellcheck.
Before you start
- ▸Basic shell scripting knowledge (variables, loops, conditionals)
- ▸A working shell script you want to make portable, or a new script to write
- ▸Root or sudo access to install packages
- ▸Optional: Docker for cross-shell container testing
Bash is everywhere on Linux, but it is not everywhere else—and even on Linux, your script may run under dash, busybox sh, or another POSIX-compatible shell. Writing portable shell scripts means they work correctly regardless of which shell executes them, making them suitable for packaging, container entrypoints, embedded systems, and cross-platform tooling. This guide walks through the common Bash-specific pitfalls, their POSIX replacements, and how to catch issues automatically before they cause runtime failures.
Why Portability Matters
On Debian and Ubuntu, /bin/sh is dash, not Bash. Scripts that start with #!/bin/sh but use Bash extensions will silently misbehave or crash. On Alpine Linux, /bin/sh is BusyBox sh. On macOS, /bin/sh is a version of Bash so old that some extensions still work—hiding bugs that only appear in production. The safest rule: if the shebang says sh, write POSIX sh.
Set Up Your Testing Environment
Install dash and shellcheck on your development machine. These two tools together catch the vast majority of portability issues.
Debian / Ubuntu
sudo apt install dash shellcheck
Fedora / RHEL / Rocky
sudo dnf install dash ShellCheck
Arch
sudo pacman -S dash shellcheck
Run any script through dash during testing—even if your production system uses Bash—because dash is strict about POSIX compliance and will fail fast on Bashisms.
dash -n myscript.sh # syntax check only
dash myscript.sh # execute under dash
Use the Correct Shebang
For a portable script, the shebang must request sh, not bash.
#!/bin/sh
set -eu
set -e exits on error; set -u treats unset variables as errors. Both are POSIX. The common set -o pipefail is not POSIX—it is supported by Bash, Zsh, and some others but not by dash or BusyBox sh. Remove it from scripts with a #!/bin/sh shebang.
If you genuinely need Bash features, be honest about it and use #!/usr/bin/env bash so the script is never run under a lesser shell.
Bashisms to Avoid
Double Brackets and Extended Tests
[[ ... ]] is a Bash keyword, not a POSIX construct. Replace it with single-bracket [ ... ] and be mindful of quoting.
# Bash only — do not use in portable scripts
if [[ $name == foo* ]]; then
# POSIX replacement
case "$name" in
foo*) : ;;
esac
Arrays
Bash arrays (arr=(a b c), ${arr[@]}) have no POSIX equivalent. For ordered lists, use positional parameters or IFS-delimited strings carefully.
# Bash only
files=(one.txt two.txt three.txt)
for f in "${files[@]}"; do process "$f"; done
# POSIX replacement — use set to load positional parameters
set -- one.txt two.txt three.txt
for f; do process "$f"; done
Here-Strings and Process Substitution
<<< (here-string) and <(command) / >(command) (process substitution) are Bash-only. Use a pipeline or a temporary file instead.
# Bash only
read -r line <<< "hello world"
# POSIX replacement
line=$(printf '%s' "hello world")
# Bash only
diff <(sort file1) <(sort file2)
# POSIX replacement — use temp files
tmp1=$(mktemp) tmp2=$(mktemp)
trap 'rm -f "$tmp1" "$tmp2"' EXIT
sort file1 > "$tmp1"
sort file2 > "$tmp2"
diff "$tmp1" "$tmp2"
Local Variables
local is widely supported but is technically not in POSIX sh. In practice it works in dash, BusyBox sh, and every common shell, so most projects treat it as safe. If you need strict purity, avoid it in functions and use unique global names. shellcheck will warn you if your target shell does not support it.
Arithmetic and let
let and (( )) are Bash extensions. POSIX arithmetic uses $(( )).
# Bash only
(( count++ ))
let count=count+1
# POSIX
count=$(( count + 1 ))
String Manipulation
Bash has ${var:offset:length}, ${var,,} (lowercase), and ${var^^} (uppercase). None are POSIX. Use parameter expansion patterns or external tools.
# Bash only
echo "${name,,}"
# POSIX replacement
echo "$name" | tr '[:upper:]' '[:lower:]'
POSIX parameter expansions that are safe to use: ${var#pattern}, ${var##pattern}, ${var%pattern}, ${var%%pattern}, ${var:-default}, ${var:=default}, ${var:?error}.
The source Builtin
Use . (dot) instead of source; the latter is Bash-only.
# Bash only
source ./lib.sh
# POSIX
. ./lib.sh
Echo Portability
The behavior of echo with flags like -n and -e varies between shells and implementations. Always use printf when you need formatting or suppressed newlines.
# Unreliable across shells
echo -n "Enter value: "
echo -e "Line1\nLine2"
# POSIX and reliable
printf 'Enter value: '
printf 'Line1\nLine2\n'
Run ShellCheck
ShellCheck is a static analyzer that flags both bugs and portability issues. Tell it to target POSIX sh specifically with --shell=sh, which enables stricter checks than the default.
shellcheck --shell=sh myscript.sh
Sample output (varies; shown for illustration):
myscript.sh:12:5: warning: In POSIX sh, arrays are undefined. [SC2039]
myscript.sh:18:8: warning: In POSIX sh, 'source' is undefined. [SC2039]
You can also embed a directive at the top of the script to lock in the target shell for editors that support ShellCheck integration:
#!/bin/sh
# shellcheck shell=sh
Integrate ShellCheck into CI so portability regressions are caught before merge:
# Example GitHub Actions step
- name: Lint shell scripts
run: shellcheck --shell=sh scripts/*.sh
Verify Your Script
After fixing ShellCheck warnings, test execution under dash directly:
dash -x myscript.sh
The -x flag prints each command before execution (like Bash's set -x), making it easy to trace failures. If you have Docker available, you can also test under Alpine's BusyBox sh with zero setup:
docker run --rm -v "$PWD":/work -w /work alpine sh myscript.sh
Troubleshooting Common Failures
- Script works in Bash but fails under dash with a syntax error: Look for
[[,((, process substitutions, or Bash-only builtins. Run ShellCheck first—it will point directly at the line. set -ucauses unexpected failures: Variables that are intentionally optional need a default:${var:-}expands to empty string without triggering the unset error.readbehaves differently: Always useread -rto prevent backslash interpretation; this is POSIX-compliant and consistent across shells.- Subshell variable changes not visible to parent: This is POSIX behavior—assignments inside a pipeline subshell do not propagate. Restructure the code to avoid relying on side effects inside pipelines.
- Temp files not cleaned up on failure: Always pair
mktempwith atrap '...' EXITas shown above;traponEXITfires even whenset -etriggers an exit.
Frequently asked questions
- Can I use local variables in a POSIX sh script?
- local is not in the POSIX specification but is supported by dash and BusyBox sh in practice. Most projects treat it as safe; shellcheck will warn you if your declared target shell rejects it.
- Is set -o pipefail safe in a #!/bin/sh script?
- No. pipefail is not POSIX and will cause a fatal error under dash. If you need pipeline failure detection, either switch to #!/usr/bin/env bash or redesign the pipeline to avoid relying on intermediate exit codes.
- What is the POSIX-safe way to read a file line by line?
- Use while IFS= read -r line; do ...; done < file. The -r flag is POSIX and prevents backslash interpretation; setting IFS= preserves leading and trailing whitespace.
- My script uses grep -P for Perl-compatible regex. Is that portable?
- No. grep -P depends on PCRE support, which is not available in all grep implementations (notably BusyBox grep lacks it). Use POSIX ERE with grep -E and POSIX character classes instead.
- How do I check whether a command exists portably?
- Use command -v name rather than which name. command -v is POSIX; which is an external utility whose behavior and exit codes vary between distributions.
Related guides
Bash Arrays and Associative Arrays
Master bash indexed and associative arrays: declaration, element access, looping, mapfile, namerefs, and practical patterns for real scripting work.
Bash Functions and Variable Scoping
Master Bash function scoping with local variables, source-based libraries, correct use of return codes, and array passing techniques including namerefs.
Bash Loops: for, while and until
Learn all three Bash loop types — for, while, and until — with practical, copy-paste examples covering file iteration, counting, polling, and safe line reading.
Bash Scripting for Beginners
Learn Bash scripting from scratch: shebang lines, variables, conditionals, loops, and arguments, plus a real backup script to tie it all together.