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.

FeatureBash syntaxPOSIX / 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

CodeIssue
SC2039Feature not supported in --posix mode (local, [[, arrays, etc.)
SC3000–SC3999Not POSIX (the SC3xxx series was added specifically for sh portability)
SC2006Backtick command substitution — use $(…)
SC2196egrep deprecated — use grep -E
SC2154Variable referenced but not assigned
SC2086Double-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:

SituationRecommended shebangRationale
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, unknown
  • SED_INPLACE — the correct arguments to achieve in-place sed editing on this platform (e.g. -i or -i '')
  • STAT_SIZE — a function stat_size FILE that prints a file's byte count portably
  • HAS_GNU_DATE1 if GNU date is available, 0 otherwise
#!/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/bin instead 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