POSIX Compliance and Portability
Chapter 2 — POSIX Compliance and Portability
Every feature in the preceding chapters relies on Bash being the interpreter. The moment your script lands on Alpine Linux (which ships BusyBox ash), a minimal Docker image, a FreeBSD jail, or a macOS CI runner, Bash extensions silently fail or behave differently. This chapter maps the boundary between portable shell and Bash-specific syntax, shows you how to audit your scripts, and teaches you to write sh-compatible code when portability genuinely matters.
1 — The Shebang Line Is a Contract
The shebang declares the interpreter and sets expectations for every line that follows.
#!/bin/bash # explicit bash — use all features freely, but requires bash at /bin/bash #!/usr/bin/env bash # portable bash — finds bash on PATH; preferred for distributed scripts #!/bin/sh # POSIX sh — must avoid ALL bash extensions #!/usr/bin/env sh # portable POSIX sh — on some systems sh IS bash (in POSIX mode)
/bin/sh is not Bash. On Debian/Ubuntu, /bin/sh is
dash — a fast, minimal POSIX-only shell. On macOS (since Catalina),
/bin/sh is a locked-down build of Bash 3.2 in POSIX mode. On Alpine Linux and
BusyBox systems it is ash. Writing #!/bin/bash and accidentally
running it with sh myscript.sh ignores the shebang entirely — the shell is whatever
sh resolves to.
Bash's POSIX mode
# Invoke bash in strict POSIX mode to test portability bash --posix myscript.sh # Or set from within a script (rarely useful in production) set -o posix
2 — The Master Compatibility Table
These are the features you reach for automatically in Bash that simply do not exist in POSIX sh.
| Feature | Bash syntax | POSIX / portable alternative |
|---|---|---|
| Arrays | arr=(a b c), ${arr[@]} |
Positional params set --, or delimited strings |
| Associative arrays | declare -A map |
No equivalent — use awk, or encode as key=value lines |
| Double-bracket test | [[ expr ]] |
[ expr ] (quote everything) or case |
| Arithmetic compound cmd | (( expr )) |
[ $((expr)) -ne 0 ] or expr |
| Process substitution | <(cmd), >(cmd) |
Named pipe (mkfifo) or temp file |
| Brace expansion | {a,b,c}, {1..5} |
Explicit list or loop |
| Here-string | <<< "string" |
printf '%s' "string" | or echo … | |
local keyword |
local var=val |
POSIX optional but widely supported; use carefully |
| String case conversion | ${var^^}, ${var,,} |
printf '%s' "$var" | tr '[:lower:]' '[:upper:]' |
| Regex matching | [[ $x =~ regex ]] |
printf '%s' "$x" | grep -Eq 'regex' or expr |
mapfile / readarray |
mapfile -t arr < file |
while IFS= read -r line; do … |
| Coprocesses | coproc NAME { … } |
Named pipes (mkfifo) |
declare / typeset |
declare -i, declare -r |
readonly for constants; no integer type |
| Extended globs | !(*.log), +(foo) |
Multiple explicit patterns or case |
$'...' quoting |
$'\n', $'\t' |
printf '\n' or $(printf '\t') |
| Source with arguments | source file arg1 |
. file (no arguments in strict POSIX) |
pipefail |
set -o pipefail |
Not available — capture each pipe stage manually |
| Namerefs | local -n ref=var |
No equivalent — use eval (carefully) or global names |
3 — ShellCheck: Automated Portability Auditing
shellcheck is the single most valuable tool for portability work. It understands both the shebang and a --shell override, and distinguishes between bugs, style issues, and portability problems.
# Audit a script for POSIX compatibility shellcheck --shell=sh myscript.sh # Check for issues at a specific POSIX standard level shellcheck --shell=sh --severity=warning myscript.sh # In CI — exit non-zero on any warning or error shellcheck -x --shell=sh myscript.sh # Check multiple files at once find . -name '*.sh' -print0 | xargs -0 shellcheck # Inline directive to suppress a specific warning # shellcheck disable=SC2039 local x='value' # SC2039: 'local' not defined in POSIX sh
Key SC codes for portability
| Code | Issue |
|---|---|
SC2039 | Feature not supported in --posix mode (local, [[, arrays, etc.) |
SC3000–SC3999 | Not POSIX (the SC3xxx series was added specifically for sh portability) |
SC2006 | Backtick command substitution — use $(…) |
SC2196 | egrep deprecated — use grep -E |
SC2154 | Variable referenced but not assigned |
SC2086 | Double-quote to prevent word splitting / globbing |
4 — Writing POSIX-Compatible Code
The discipline is: reach for the portable form first, and use Bash extensions only when there is a genuine reason. Here are the most common translation patterns.
Replacing [[ ]] with [ ] and case
## Bash ──────────────────────────────────────── [[ $str == foo* ]] # pattern match [[ $str =~ ^[0-9]+$ ]] # regex match [[ -n $x && -f $file ]] # compound test ## POSIX ──────────────────────────────────────── case "$str" in foo*) echo match ;; esac # pattern via case case "$str" in # digit-only check *[!0-9]*|'') echo "not integer" ;; *) # is integer esac [ -n "$x" ] && [ -f "$file" ] # separate [ ] calls
Replacing arrays with positional parameters
## Bash ──────────────────────────────────────── items=( one two three ) for item in "${items[@]}"; do echo "$item"; done ## POSIX ──────────────────────────────────────── set -- one two three for item in "$@"; do echo "$item"; done # When you need to preserve $@ for the original arguments, # use a wrapper function — each function has its own $@ process_list() { set -- one two three # local to this function's positional params for item in "$@"; do echo "$item"; done }
Replacing here-strings
## Bash grep 'pattern' <<<"$variable" ## POSIX printf '%s\n' "$variable" | grep 'pattern' # or, if the variable might lack a trailing newline: printf '%s' "$variable" | grep 'pattern'
Replacing $'...' quoting
## Bash NL=$'\n' TAB=$'\t' ## POSIX NL=$(printf '\n') # but trailing newline stripped! use sentinel: NL=$(printf '\nx'); NL="${NL%x}" TAB=$(printf '\t')
Replacing pipefail
## Bash set -o pipefail result=$(generate | transform | finalise) ## POSIX — capture intermediate exit codes manually tmp=$(mktemp) generate > "$tmp"; rc1=$? result=$(transform < "$tmp" | finalise); rc2=$? rm -f "$tmp" [ $rc1 -eq 0 ] && [ $rc2 -eq 0 ] || { echo "pipeline failed" >&2; exit 1; }
Portable function-local variables
# 'local' is not in POSIX but is universally supported in practice # (dash, ash, ksh, zsh all support it). The shellcheck SC2039 warning # is technically correct but pragmatically safe to suppress for most targets. # If targeting strict POSIX: use unique global names with a function prefix. _mylib_process() { # POSIX-strict: prefix all vars to avoid collisions instead of local _mylib_process_input="$1" _mylib_process_result='' # … work … }
5 — Platform-Specific Traps
macOS / BSD differences
macOS ships BSD userland tools alongside its own /bin/sh (Bash 3.2 in POSIX mode). The GNU tools and BSD tools share many flags but differ in critical ways.
## sed -i behaves differently # GNU sed (Linux): sed -i 's/foo/bar/' file.txt # in-place, no backup # BSD sed (macOS): sed -i '' 's/foo/bar/' file.txt # requires empty-string extension argument # Portable workaround: sed 's/foo/bar/' file.txt > file.txt.tmp && mv file.txt.tmp file.txt ## stat differs # GNU stat (Linux): stat --format='%s' file.txt # BSD stat (macOS): stat -f '%z' file.txt # Portable — use wc -c or ls: size=$(wc -c < file.txt) ## date -d is GNU-only # GNU: date -d '2 days ago' '+%Y-%m-%d' # BSD/macOS: date -v-2d '+%Y-%m-%d' # Portable — use Python or perl if available: python3 -c "from datetime import date,timedelta; print(date.today()-timedelta(2))" ## grep -P (PCRE) is GNU-only grep -P '\d+' file # Linux only grep -E '[0-9]+' file # portable ERE — works everywhere ## echo -e / echo -n are not portable echo -e "line1\nline2" # implementation-defined: some echo ignore -e printf 'line1\nline2\n' # always correct
BusyBox / Alpine Linux
# BusyBox ash: limited parameter expansion, no arrays, no [[, no (()) # Also missing from common BusyBox builds: # - read -a (array read) # - printf %q # - jobs -l # - kill -l with signal names # Test in BusyBox ash without leaving your machine: docker run --rm -v "$PWD:/work" alpine ash /work/myscript.sh # Or interactively: docker run --rm -it -v "$PWD:/work" alpine sh
Detecting the platform at runtime
is_macos() { [ "$(uname)" = 'Darwin' ]; } is_linux() { [ "$(uname)" = 'Linux' ]; } is_busybox() { busybox --help >/dev/null 2&1 } # Portable sed in-place using a wrapper sed_inplace() { local expr="$1" file="$2" if is_macos; then sed -i '' "$expr" "$file" else sed -i "$expr" "$file" fi } # Detect gnu vs bsd coreutils once, export for the rest of the script if date --version >/dev/null 2&1; then GNU_DATE=1 # GNU date supports --version else GNU_DATE=0 # BSD/macOS date does not fi
6 — The Decision Framework: When to Use What
Portability has a cost — portable code is usually more verbose and sometimes slower. Make the decision deliberately:
| Situation | Recommended shebang | Rationale |
|---|---|---|
| Personal tooling, development machine | #!/usr/bin/env bash |
Use Bash freely; clarity over portability |
| Distributed tool, unknown target systems | #!/usr/bin/env bash + check Bash version |
Require Bash explicitly; document the requirement |
| Docker base-image entrypoint, Alpine image | #!/bin/sh |
ash is the only shell; no Bash available |
| System init script, /etc/profile.d/ | #!/bin/sh |
Must work before Bash may be installed |
| CI/CD pipeline shared across Linux + macOS | #!/usr/bin/env bash |
Bash 5 on macOS via Homebrew; document the dep |
| Library meant to be sourced by others | No shebang; document minimum shell version | Caller controls the interpreter |
Checking the Bash version at startup
#!/usr/bin/env bash # Require bash 4.2+ for associative arrays and [[ -v ]] if (( BASH_VERSINFO[0] < 4 || ( BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 2 ) )); then printf '%s requires bash >= 4.2 (have %s)\n' \ "$(basename "$0")" "$BASH_VERSION" >&2 exit 1 fi # macOS ships Bash 3.2 — this guard catches it cleanly
7 — A Portable Script Template
This template deliberately avoids every Bash extension. It works correctly under dash, ash, ksh88, and any POSIX-conformant shell.
#!/bin/sh # ============================================================================= # portable_tool.sh — POSIX sh only, no Bash extensions # Tested: dash 0.5, ash (BusyBox 1.36), ksh93 # ============================================================================= set -eu # -e and -u are POSIX; -o pipefail is NOT PROG="${0##*/}" # basename without a fork die() { printf '%s: %s\n' "$PROG" "$*" >&2; exit 1; } usage() { printf 'Usage: %s [OPTIONS] FILE\n' "$PROG" printf ' -v verbose\n -h help\n' } VERBOSE=0 while [ $# -gt 0 ]; do case "$1" in -v) VERBOSE=1 ;; -h) usage; exit 0 ;; --) shift; break ;; -*) die "unknown option: $1" ;; *) break ;; esac shift done [ $# -eq 0 ] && { usage >&2; exit 1; } [ -f "$1" ] || die "file not found: $1" # Main logic — using only POSIX features while IFS= read -r line; do [ "$VERBOSE" = "1" ] && printf 'DEBUG: %s\n' "$line" >&2 printf '%s\n' "$line" done < "$1"
8 — Portability Anti-Patterns to Avoid
## Anti-pattern 1: assuming /usr/bin/env exists # On stripped-down embedded systems, env may not be at /usr/bin/env #!/usr/bin/env bash # fine for general use #!/bin/bash # more portable on Linux where bash is always at /bin/bash ## Anti-pattern 2: parsing ls output for f in $(ls *.txt); do # WRONG — breaks on spaces, special chars for f in *.txt; do # RIGHT — glob directly ## Anti-pattern 3: using echo for arbitrary data echo "$variable" # risky if variable starts with - or contains \n printf '%s\n' "$variable" # always correct ## Anti-pattern 4: [ $var == value ] (double equals in [) [ "$var" == "value" ] # == is not POSIX in [ ] — works in bash/dash but not all [ "$var" = "value" ] # = is POSIX — always use this in [ ] ## Anti-pattern 5: unportable test flags [ -a file1 -a file2 ] # -a as compound operator is not reliable [ -e file ] # -e not in original POSIX (but in SUSv3+ and all modern sh) [ -f file ] # -f is always safe ## Anti-pattern 6: backtick nesting result=`echo \`date\`` # hard to read; escaping is fragile result=$(echo $(date)) # $(()) nests cleanly — use this always ## Anti-pattern 7: word splitting on unquoted $@ bad() { echo $@; } # loses quoting from caller good() { echo "$@"; } # preserves individual arguments
Exercises
Exercise 1 — ShellCheck audit and fix
The script below is written with Bash idioms but has the shebang
#!/bin/sh. Run it through shellcheck --shell=sh and fix
every portability issue. The fixed script must produce identical output under
both bash and dash.
#!/bin/sh
items=(alpha beta gamma)
for item in "${items[@]}"; do
if [[ $item == g* ]]; then
echo "Found: $item"
fi
done
result=$(echo "Total: ${#items[@]}")
#!/bin/sh # Fixed: no arrays, no [[, using case for pattern match, using set -- for list set -- alpha beta gamma _count=$# # capture count before any further set -- for item in "$@"; do case "$item" in g*) printf 'Found: %s\n' "$item" ;; esac done result=$(printf 'Total: %d' "$_count") printf '%s\n' "$result" # Changes made: # 1. arrays → set -- / "$@" # 2. ${#items[@]} → captured $# before the loop # 3. [[ $item == g* ]] → case statement # 4. echo "..." → printf '%s\n' "..." (echo not portable with $result)
Exercise 2 — Portable platform detection library
Write a POSIX-compatible library lib/platform.sh (no Bash extensions)
that detects and exports the following variables when sourced:
PLATFORM— one of:linux,macos,freebsd,unknownSED_INPLACE— the correct arguments to achieve in-place sed editing on this platform (e.g.-ior-i '')STAT_SIZE— a functionstat_size FILEthat prints a file's byte count portablyHAS_GNU_DATE—1if GNU date is available,0otherwise
#!/bin/sh # lib/platform.sh — POSIX sh only [ -n "${_LIB_PLATFORM_LOADED:-}" ] && return 0 _LIB_PLATFORM_LOADED=1 PLATFORM='unknown' case $(uname -s) in Linux) PLATFORM='linux' ;; Darwin) PLATFORM='macos' ;; FreeBSD) PLATFORM='freebsd' ;; esac # sed -i flag case "$PLATFORM" in macos|freebsd) SED_INPLACE="-i ''" ;; *) SED_INPLACE='-i' ;; esac # Portable file size function stat_size() { _f="${1:?stat_size: FILE required}" case "$PLATFORM" in linux) stat --format='%s' "$_f" ;; macos|freebsd) stat -f '%z' "$_f" ;; *) # Universal fallback: wc -c handles any POSIX system wc -c < "$_f" | tr -d ' ' ;; esac } # GNU date detection HAS_GNU_DATE=0 date --version >/dev/null 2&1 && HAS_GNU_DATE=1 export PLATFORM SED_INPLACE HAS_GNU_DATE
Exercise 3 — Portable pipefail wrapper
Write a POSIX sh function pipe_all CMD1 CMD2 CMD3 that:
- Executes
CMD1 | CMD2 | CMD3 - Returns exit code 0 only if all three commands succeeded
- Prints which stage(s) failed to stderr
- Works in dash and ash — no
pipefail, no arrays, no[[
Hint: named pipes (mkfifo) let you interpose between stages and capture each exit code.
pipe_all() { # Usage: pipe_all CMD1 CMD2 CMD3 # Runs CMD1 | CMD2 | CMD3, fails if any stage fails. # Uses temp files to capture exit codes since POSIX sh has no pipefail. [ $# -eq 3 ] || { printf 'pipe_all: exactly 3 commands required\n' >&2; return 1; } _cmd1="$1" _cmd2="$2" _cmd3="$3" # Temp files for inter-stage data and exit codes _t1=$(mktemp); _t2=$(mktemp) _rc1=$(mktemp); _rc2=$(mktemp); _rc3=$(mktemp) # Cleanup on any exit trap 'rm -f "$_t1" "$_t2" "$_rc1" "$_rc2" "$_rc3"' EXIT INT TERM # Stage 1 $_cmd1 > "$_t1"; printf '%d' $? > "$_rc1" # Stage 2 $_cmd2 < "$_t1" > "$_t2"; printf '%d' $? > "$_rc2" # Stage 3 $_cmd3 < "$_t2"; printf '%d' $? > "$_rc3" _r1=$(cat "$_rc1") _r2=$(cat "$_rc2") _r3=$(cat "$_rc3") _ok=0 [ "$_r1" = "0" ] || { printf 'pipe_all: stage 1 failed (exit %s)\n' "$_r1" >&2; _ok=1; } [ "$_r2" = "0" ] || { printf 'pipe_all: stage 2 failed (exit %s)\n' "$_r2" >&2; _ok=1; } [ "$_r3" = "0" ] || { printf 'pipe_all: stage 3 failed (exit %s)\n' "$_r3" >&2; _ok=1; } return "$_ok" }
Exercise 4 — Cross-platform install script
Write a POSIX sh install script install.sh for a fictional tool
mytool that:
- Works on Linux (GNU coreutils), macOS (BSD tools), and Alpine (BusyBox)
- Detects whether the user is root; if not, installs to
~/.local/bininstead of/usr/local/bin - Copies the binary, sets it executable, and verifies the install location is on PATH
- Prints a clear warning (not an error) if the install dir is not on PATH
- Uses only POSIX-portable shell constructs — no Bash extensions
#!/bin/sh set -eu PROG="${0##*/}" BINARY='./mytool' # source binary in current directory die() { printf '%s: error: %s\n' "$PROG" "$*" >&2; exit 1; } warn() { printf '%s: warning: %s\n' "$PROG" "$*" >&2; } info() { printf '%s: %s\n' "$PROG" "$*"; } # Source binary must exist [ -f "$BINARY" ] || die "source binary not found: $BINARY" # Choose install directory if [ "$(id -u)" = '0' ]; then INSTALL_DIR=/usr/local/bin else INSTALL_DIR="${HOME}/.local/bin" fi # Create install directory if absent [ -d "$INSTALL_DIR" ] || mkdir -p "$INSTALL_DIR" || die "cannot create directory: $INSTALL_DIR" # Copy and make executable cp "$BINARY" "$INSTALL_DIR/mytool" || die "copy failed" chmod '755' "$INSTALL_DIR/mytool" || die "chmod failed" info "Installed mytool to $INSTALL_DIR/mytool" # Check if INSTALL_DIR is on PATH (POSIX: use case to search PATH components) _found=0 _oldIFS="$IFS" IFS=':' for _dir in $PATH; do [ "$_dir" = "$INSTALL_DIR" ] && { _found=1; break; } done IFS="$_oldIFS" if [ "$_found" = "0" ]; then warn "$INSTALL_DIR is not on your PATH" printf ' Add this to your shell profile:\n' printf ' export PATH="%s:$PATH"\n' "$INSTALL_DIR" else info "mytool is ready: $(command -v mytool 2>/dev/null || printf '%s/mytool' "$INSTALL_DIR")" fi