The first ten lessons of this course assumed bash. Tier 3 reinforced that assumption — [[ ]], ${var,,}, mapfile, <() process substitution. Most scripts you write should remain bash, because bash’s quality-of-life features prevent more bugs than they introduce.
But three real situations force you to drop to POSIX /bin/sh:
- Alpine and busybox-based containers ship
ash, not bash.apk add bashworks, but if you’re building a base image for everyone, you don’t get to assume bash exists. - Init/recovery contexts — Debian’s
/bin/shisdash(faster, smaller). Init scripts, package post-install hooks, andcloud-initboothooks must work under dash. - OpenWrt, Yocto, embedded Linux — your shell is busybox. There’s no bash, ever.
When you write code for those contexts, bash-isms are the enemy and many of the things you take for granted simply don’t work. This lesson is the complete decision framework: when to pick which dialect, what POSIX guarantees vs what bash adds, the automated detection tools, and how to write scripts that work under both.
By the end, you’ll know exactly which features to use, which to avoid, and how to verify portability without manually testing on five OSes.
In a nutshell
Every shell script starts with a line like #!/bin/sh or #!/usr/bin/env bash, and most people treat those as interchangeable ways of saying “this is a shell script.” They are not. They select two different languages that happen to share a lot of vocabulary.
Think of POSIX sh as the phrasebook edition of shell — the few hundred words and grammar rules that every shell on Earth is guaranteed to understand, printed in the little booklet that ships inside dash, ash, busybox, ksh, and bash alike. Bash is the full, fluent, idiomatic dialect: the same base language plus decades of convenient extras — arrays, [[ ]], ${name,,}, process substitution. When you write bash and run it on a machine that only carries the phrasebook — a Debian boot script, an Alpine container, a home router — your fluent idioms come out as gibberish: [[: not found, Bad substitution, Syntax error: "(" unexpected. The machine isn’t broken and your script isn’t wrong. You just spoke words that aren’t in the booklet.
So this lesson answers three questions precisely. Which words are in the phrasebook (POSIX) and which are bash-only extras? How do you detect an accidental bash-ism before it ships, without owning five different operating systems? And when should you deliberately limit yourself to the phrasebook versus enjoy the full dialect? The honest answer to the last one is “write bash unless a concrete deployment target forces POSIX” — but those targets are real, and when you hit one, every shortcut you reach for is a potential runtime failure.
The single most important fact to carry out of here: #!/bin/sh is not a synonym for bash. It is a contract that says “I will use only POSIX features,” and on most real Linux systems /bin/sh is dash or busybox, which enforce that contract ruthlessly.
Level: Advanced → Expert · Time: ~44 min · Prerequisites: the shell & dialect process model (what dash/ash/busybox actually are), variables, quoting & parameter expansion (so ${var#prefix} and word-splitting are familiar), [ ], exit codes & conditionals, and arrays (the single biggest thing POSIX takes away). If you can read a strict-mode bash script comfortably, you’re ready.
![POSIX portability decision flow, read left to right in five stages. THE CONTRACT: line 1 picks the language — #!/bin/sh means the POSIX subset only, #!/usr/bin/env bash means the full bash dialect. BASH-ISMS: the tempting non-POSIX features — [[ ]], arr=( ), ==, ${v//}, ${v:off:len}, process substitution <() — which abort under /bin/sh with '[: not found’ or ‘Bad substitution’. THE POSIX SWAP: the portable equivalents — [ ] plus case, $@, printf, ${v#prefix}, command -v — the last two of which already are POSIX. DETECT: prove portability without five machines using shellcheck -s sh (flags SC2039), checkbashisms, and dash -n / busybox sh -n syntax checks. THE TARGET: where /bin/sh really is — dash, ash, busybox — which is not bash, and where pure POSIX earns a 20-year shelf life. Six numbered badges mark the shebang contract, the two bash-ism groups, the POSIX swap, the detection layer, and the dash/busybox reality.
Read the diagram left → right as the life of a portability decision: line 1 is a contract (zone 1), the bash-isms are the temptation that break the contract at runtime (zone 2), every bash-ism has a portable swap while ${var#}, $(( )) and command -v are already POSIX and stay (zone 3), you prove portability with shellcheck -s sh + checkbashisms + dash -n + busybox sh -n instead of five machines (zone 4), and the reason it all matters is that /bin/sh on the box you ship to is dash or busybox, not bash (zone 5).
1. The dialect tree — who runs what
"shell"
├── POSIX shell (the standard, abstract)
│ ├── dash — Debian/Ubuntu /bin/sh (default since ~2006)
│ ├── ash — Alpine, busybox /bin/sh
│ ├── busybox sh — Yocto, OpenWrt, embedded
│ └── ksh93 — AIX, some Solaris, some BSDs
├── bash — Linux default user shell, macOS user shell (via brew)
├── zsh — macOS default user shell since Catalina
└── fish — interactive-only, not a script target
The crucial fact: on most Linux servers, /bin/sh is NOT bash. It’s a symlink to dash (Debian/Ubuntu) or to bash-in-POSIX-mode (Red Hat) or to ash (Alpine). When your script’s shebang says #!/bin/sh, you do not have bash.
$ ls -l /bin/sh # Debian/Ubuntu
lrwxrwxrwx 1 root root 4 ... /bin/sh -> dash
$ ls -l /bin/sh # Alpine
lrwxrwxrwx 1 root root 12 ... /bin/sh -> /bin/busybox
$ ls -l /bin/sh # Red Hat/Fedora
lrwxrwxrwx 1 root root 4 ... /bin/sh -> bash # bash, but invoked as 'sh' enables POSIX mode
Even where /bin/sh is bash, invoking bash as sh enables POSIX mode and disables many bash-isms. So #!/bin/sh is a contract: “I will use only POSIX features.” (One caveat worth knowing now and unpacking in Going deeper: bash’s POSIX mode does not remove [[ ]] or arrays — it is a weaker gate than it looks.)
1.1 The default-shell history
Why is this confusing? Because it changed:
- 1990s:
/bin/shwas nearly always Bourne shell or bash. - ~2006: Debian/Ubuntu switched
/bin/shto dash for boot speed. - Always: Alpine/busybox uses ash.
- Always: macOS user
/bin/shis bash 3.2, but/bin/zshbecame the user default in Catalina (2019).
If you wrote bash in 2002 and it worked when invoked as /bin/sh, that’s because /bin/sh was bash. The same script today fails on a fresh Ubuntu install — same code, different /bin/sh.
2. The bash-isms — features POSIX doesn’t have
Here’s the canonical list of features bash adds over POSIX. Each one is something you’ll be tempted to use; in /bin/sh mode, each one breaks (or is silently mis-parsed).
2.1 Conditionals: [[ ... ]]
# Bash:
[[ $a == "hello" ]]
[[ $a == hello* ]]
[[ $a =~ ^[0-9]+$ ]]
[[ -f $file && -r $file ]]
# POSIX equivalent:
[ "$a" = "hello" ]
case $a in hello*) ;; *) ;; esac
echo "$a" | grep -Eq '^[0-9]+$'
[ -f "$file" ] && [ -r "$file" ]
Note: POSIX [ ] requires spaces around brackets and quoted variables. The biggest hazard is [ -f $file ] — if $file contains spaces, this becomes [ -f hello world ] and errors. Always quote inside [ ].
[[ ... ]] doesn’t have the quoting requirement, doesn’t word-split, supports regex, supports && directly. It’s safer in every way — but it’s not POSIX.
2.2 Arrays
POSIX has no arrays. None — not indexed, not associative.
# Bash:
arr=(one two three)
echo "${arr[1]}" # two
for x in "${arr[@]}"; do echo "$x"; done
# POSIX — use $@ as the only "array":
set -- one two three
echo "$2" # two
for x in "$@"; do echo "$x"; done
This is the biggest constraint. Almost every bash script that does something interesting uses arrays; you have to redesign around using $@ (the positional parameters) or per-line strings.
2.3 String manipulation
# Bash:
echo "${var,,}" # lowercase
echo "${var^^}" # uppercase
echo "${var:2:5}" # substring
echo "${var//foo/bar}" # global replace (this IS POSIX as of 2024 standard)
# POSIX equivalents (universal, work everywhere):
echo "$var" | tr '[:upper:]' '[:lower:]'
echo "$var" | tr '[:lower:]' '[:upper:]'
echo "$var" | cut -c3-7
echo "$var" | sed 's/foo/bar/g'
# Some prefix/suffix ops ARE POSIX:
echo "${var#prefix}" # POSIX — strip shortest matching prefix
echo "${var%suffix}" # POSIX — strip shortest matching suffix
echo "${var##prefix*}" # POSIX — longest prefix
echo "${var%%*suffix}" # POSIX — longest suffix
The four #/##/%/%% operators are POSIX and work everywhere. ${var,,} and ${var:start:len} are bash-only. (The ${var//foo/bar} note above has a twist — see Going deeper: the 2024 paper standard and the dash actually on your box disagree.)
2.4 local keyword
# Bash:
my_function() {
local var=value
...
}
# POSIX — no `local`. dash and most ash do support it as an extension.
# Truly portable: declare in a subshell or use a unique name prefix.
my_function() ( # ← parens, not braces; subshell
var=value
...
)
local is not in POSIX but is supported by dash, busybox ash, and ksh as an extension. In practice, you can usually use local even in #!/bin/sh scripts — but shellcheck -s sh will warn, and it’ll fail on truly minimal POSIX shells.
The bullet-proof workaround is the subshell function: define the function with (...) instead of {...}. The function runs in a subshell, so all assignments are local by definition. Cost: you can’t return values via global assignment; you have to use stdout.
2.5 Process substitution <(cmd) and >(cmd)
# Bash:
diff <(sort file1) <(sort file2)
while IFS= read -r line; do ...; done < <(grep foo file)
# POSIX — must use temp files or pipes:
sort file1 > /tmp/s1.$$
sort file2 > /tmp/s2.$$
diff /tmp/s1.$$ /tmp/s2.$$
rm -f /tmp/s1.$$ /tmp/s2.$$
# Or via a pipe (loses subshell variable scope):
grep foo file | while IFS= read -r line; do ...; done
The pipe version is correct POSIX, but variables set inside the loop don’t survive the subshell. This is the main reason <(cmd) is so heavily used in bash; you have to redesign around it in POSIX.
2.6 Arithmetic syntax
# Bash:
i=$((i + 1))
((count++))
((i < 10)) && echo less
let i=i+1
# POSIX:
i=$((i + 1)) # SAME — $((...)) is POSIX
[ "$((i < 10))" -ne 0 ] && echo less # uglier
# (( )), let, ++ are NOT POSIX
$((arithmetic)) is fully POSIX. The ergonomic shortcuts ((...)), let, and ++ are bash-isms.
2.7 echo -e and echo -n
echo -e (interpret escapes) and echo -n (no newline) are not POSIX and behave inconsistently across shells. printf is portable:
# Bash (works, but not portable):
echo -n "no newline"
echo -e "with\ttab"
# POSIX (always works):
printf '%s' "no newline"
printf 'with\ttab\n'
Rule: never use echo in scripts. Use printf. We’ve reinforced this from L2 onward; in POSIX scripts it’s mandatory. (Just how broken echo is under dash — printing -e literally and expanding \t in the same breath — is worth seeing; there’s a verified demonstration in Going deeper.)
2.8 Here-strings <<<
# Bash:
grep foo <<< "$var"
# POSIX:
printf '%s\n' "$var" | grep foo
# or:
grep foo <<EOF
$var
EOF
Here-strings are bash. Heredocs (<<EOF) are POSIX.
2.9 $RANDOM, $EPOCHSECONDS, $BASHPID
# Bash:
echo $RANDOM # random int 0..32767
echo $EPOCHSECONDS # bash 5.0+
echo $BASHPID # current shell PID, even in subshells
# POSIX:
awk 'BEGIN{srand(); print int(rand()*32768)}' # random
date +%s # epoch
echo $$ # PID (subshell-aware: not necessarily)
$$ in POSIX returns the parent shell’s PID, even from inside a subshell. $BASHPID is the current shell’s PID, including subshells. Different semantics — only matters for some patterns, but watch out.
2.10 Other notable absences
# Bash extensions, not POSIX:
mapfile -t arr < file # use a while-read loop
readarray ... # alias for mapfile
$(< file) # read whole file; use $(cat file)
${var:-default} # ← actually POSIX (this works)
${var:=default} # ← also POSIX
${var:+alt} # ← POSIX
${var:?error} # ← POSIX (great for required vars)
declare, typeset # bash builtins; some sh have typeset
shopt # bash only
trap '...' ERR # ERR is bash; POSIX has only EXIT, INT, TERM, etc.
trap '...' DEBUG # bash only
The four “expansion forms” with : work in POSIX. ${var:?msg} is particularly valuable — it errors out with msg if $var is unset or empty:
: "${REQUIRED_VAR:?REQUIRED_VAR must be set}"
This is the most portable way to assert “this variable must be set” — works in dash, ash, busybox, everywhere.
3. Decision framework — when to pick which
The right choice depends on what runs your script:
| Context | Shell | Why |
|---|---|---|
| User-facing CLI tool, devs install it | #!/usr/bin/env bash |
bash quality-of-life > portability burden |
| Internal ops scripts (servers you control) | #!/usr/bin/env bash |
Same |
Linux distro /etc/init.d/* (legacy SysV) |
#!/bin/sh |
Init runs early, before bash exists in initramfs |
Debian/Ubuntu postinst, prerm, etc. |
#!/bin/sh |
Policy: must run on systems without bash |
Alpine/busybox container ENTRYPOINT.sh |
#!/bin/sh |
bash isn’t installed by default |
cloud-init bootcmd / runcmd (raw shell) |
#!/bin/sh |
Boothooks must run before any extra packages |
Inside Dockerfile RUN |
doesn’t matter — shell is /bin/sh of the base image |
But you can RUN bash -c '...' if needed |
| OpenWrt/embedded | #!/bin/sh |
busybox ash is your only option |
Apple /usr/bin/env bash scripts on macOS |
bash | Users can brew install bash |
Rule of thumb: write bash unless you have a concrete reason why bash won’t be available. The reasons are real, but rare in modern DevOps.
3.1 The hybrid pattern
If a script must work under both, pick POSIX as the contract and shell-detect for optional improvements:
#!/bin/sh
# Run as POSIX sh by default. Use bash features only when bash is detected.
set -eu
if [ -n "${BASH_VERSION:-}" ]; then
# Bash-specific niceties.
set -o pipefail
fi
# Rest of script uses POSIX-only features.
pipefail is bash-only. Setting it conditionally lets the script run safely under both, with extra protection on bash. Use this pattern sparingly — divergent codepaths are hard to maintain.
4. Detecting bash-isms automatically
Three tools, increasing in strictness.
4.1 shellcheck -s sh — POSIX-mode static analysis
You’ve seen shellcheck from L13. It defaults to -s bash on #!/bin/bash and -s sh on #!/bin/sh. To force POSIX checking on a bash file:
shellcheck -s sh myscript.sh
It flags every bash-ism with code SC2039 (and friends). Sample output:
In myscript.sh line 4:
if [[ -f "$file" ]]; then
^-- SC2039: In POSIX sh, [[ ]] is undefined.
In myscript.sh line 6:
arr=(one two three)
^-- SC2039: In POSIX sh, array references are undefined.
For a script that must be POSIX, run shellcheck -s sh in CI and treat warnings as failures.
4.2 checkbashisms — Debian’s tool, even stricter
Debian ships checkbashisms (in the devscripts package). It catches things shellcheck misses, including some non-trivial parsing edge cases:
sudo apt install devscripts
checkbashisms /etc/init.d/myservice
possible bashism in /etc/init.d/myservice line 4 ('echo -e'):
echo -e "starting\n"
possible bashism in /etc/init.d/myservice line 12 ('${var,,}'):
NAME=${name,,}
checkbashisms is the canonical tool for Debian’s “must run under dash” rule and is more pedantic than shellcheck about borderline cases.
4.3 Run the script under dash
The empirical test: install dash and actually invoke your script:
sudo apt install dash
dash myscript.sh # Run with dash directly, ignoring shebang.
If it runs with dash, it’s portable. This catches things static analysis misses (rare runtime behaviour, dynamic feature use).
4.4 CI for POSIX scripts
A reasonable CI matrix for any “must be POSIX” script:
- name: shellcheck
run: shellcheck -s sh script.sh
- name: checkbashisms
run: |
sudo apt-get install -y devscripts
checkbashisms script.sh
- name: dash
run: dash script.sh --self-test || true # if your script supports it
- name: busybox sh
run: |
docker run --rm -v "$PWD:/work" -w /work busybox sh script.sh --self-test
Three layers: static (shellcheck), strict-static (checkbashisms), runtime under dash, and runtime under busybox. If all four pass, the script is portable in practice.
5. Writing portable shell — the discipline
If you’ve decided to write #!/bin/sh, here’s the day-to-day playbook.
5.1 The portable script preamble
#!/bin/sh
# myscript - description
# Copyright (c) 2024 Your Name. License: ...
set -eu # No pipefail (bash-only); no -E (bash-only)
# Required vars (use ${var:?msg} pattern):
: "${MYAPP_HOME:?MYAPP_HOME must be set}"
: "${MYAPP_USER:?MYAPP_USER must be set}"
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH
TZ=UTC
export TZ
LC_ALL=C
export LC_ALL
Note: POSIX has no export VAR=value in one statement officially — you must VAR=value; export VAR separately. (In practice, export VAR=value works in dash/ash/busybox, but shellcheck -s sh warns about it; the two-line form is the strictest portable.)
5.2 Functions
# Subshell-form function: locals are automatic.
my_func() (
arg1=$1
arg2=$2
printf '%s\n' "$arg1 $arg2"
)
# Or with declare-style locals (works on dash/ash/busybox; not strictly POSIX):
my_func() {
local arg1=$1
local arg2=$2
printf '%s\n' "$arg1 $arg2"
}
5.3 Iteration over filenames
The filename-safe iteration pattern from L4 has to use find -print0 | xargs -0 (no mapfile). The cleanest POSIX is xargs:
find /var/log -name '*.log' -mtime +30 -print0 | xargs -0 rm -f --
Or, if you need per-file logic:
find /var/log -name '*.log' -print0 | while IFS= read -r -d '' f; do
process_file "$f"
done
-d '' is bash-only; POSIX read doesn’t accept -d. In strict POSIX, use a different sentinel:
# Awful but portable: assume no newlines in filenames (often true in practice).
find /var/log -name '*.log' | while IFS= read -r f; do
process_file "$f"
done
Or use xargs exclusively and design your tool around per-batch invocation rather than per-file logic.
5.4 Arrays — using $@ as the one-and-only
set -- one two three # Loads $@ with three values
shift # Drops $1; now $@ is "two three"
for x in "$@"; do
printf '> %s\n' "$x"
done
# Adding to "the array":
set -- "$@" four # Append "four"
# Reading "array" length:
echo "$#" # 4
# Indexing:
eval "x=\${$2}" # awful; just don't index
# Iterating:
for x in "$@"; do ... done
POSIX has only one “array” — $@/$* and the positional parameters. Functions clobber the outer $@ by default. To preserve it:
my_func "$@" # Pass it in, untouched
Or save and restore:
saved="$*"
my_func arg1 arg2
set -- $saved # ← unquoted on purpose, word-splits
(That last pattern is dangerous — only works if values don’t contain spaces.)
The sane workaround for “I really need a second array” is multi-line strings:
files='one
two
three'
echo "$files" | while IFS= read -r f; do process "$f"; done
Newline-separated strings are the POSIX equivalent of an array. They work great for filenames (with the caveat that newlines in filenames break them) and just-fine for everything else.
5.5 Associative-array workarounds
POSIX has no associative arrays. Two patterns:
Eval-based dynamic variables (works, but make sure your keys are safe):
set_attr() { eval "ATTR_$1=\"\$2\""; }
get_attr() { eval "echo \"\${ATTR_$1:-}\""; }
set_attr name "alice"
set_attr age 30
echo "$(get_attr name)" # alice
echo "$(get_attr age)" # 30
Validate keys: case $1 in [A-Za-z_][A-Za-z0-9_]*) ;; *) error;; esac before calling, otherwise it’s an injection vector.
File-based key-value (slower, but inherently safe):
ATTRS=$(mktemp)
trap 'rm -f "$ATTRS"' EXIT
# Set:
printf '%s\t%s\n' name alice >> "$ATTRS"
printf '%s\t%s\n' age 30 >> "$ATTRS"
# Get (most-recent value of key):
get_attr() {
awk -v k="$1" 'BEGIN{FS="\t"} $1==k { v=$2 } END{print v}' "$ATTRS"
}
echo "$(get_attr name)" # alice
Pick the pattern that fits the script’s complexity. Most POSIX scripts don’t need associative arrays at all — they’re a luxury.
5.6 Math
# All POSIX:
i=$((i + 1))
i=$((i * 2 + 3))
[ "$((i < 10))" -eq 1 ] && echo "less"
# But beware: variables in $(( )) DO NOT need $ prefix in many shells:
i=5
echo $((i + 1)) # 6 — POSIX
echo $(($i + 1)) # 6 — also works, but $ is redundant inside $(( ))
$(( )) is one of the better-supported POSIX features. Use it freely.
5.7 String compare with case patterns
[[ $a == prefix* ]] doesn’t exist in POSIX. The portable form is case:
case "$a" in
prefix*) echo "starts with prefix" ;;
*suffix) echo "ends with suffix" ;;
*foo*) echo "contains foo" ;;
*) echo "no match" ;;
esac
POSIX case glob patterns are surprisingly powerful: [abc], ?, * all work. The order of cases matters — the first match wins.
5.8 Conditional execution
# POSIX:
if [ -f "$f" ]; then
echo "exists"
elif [ -d "$f" ]; then
echo "directory"
else
echo "neither"
fi
# Combine with && and ||:
[ -f "$f" ] && process "$f"
[ -f "$f" ] || { echo "missing"; exit 1; }
POSIX [ ] operators worth knowing:
[ -f file ] # Regular file exists
[ -d dir ] # Directory exists
[ -e path ] # Path exists (any type)
[ -r file ] # File is readable
[ -w file ] # File is writable
[ -x file ] # File is executable
[ -z "$s" ] # String is empty
[ -n "$s" ] # String is non-empty
[ "$a" = "$b" ] # String equality (= not ==)
[ "$a" != "$b" ] # String inequality
[ "$n" -eq "$m" ] # Numeric equality
[ "$n" -lt "$m" ] # Numeric less-than
[ "$n" -gt "$m" ] # Numeric greater-than
POSIX [ ] does not support && and || inside the brackets — chain them outside. POSIX [ ] does support -a (and) and -o (or), but they’re deprecated as of POSIX 2008 (return value can be ambiguous when args have special characters).
5.9 The “test for command existence” idiom
# POSIX:
if command -v jq >/dev/null 2>&1; then
echo "jq is available"
fi
# Bash-specific (don't use for portable code):
if type -P jq >/dev/null; then ...; fi
if hash jq 2>/dev/null; then ...; fi
command -v is the POSIX-mandated way and works everywhere — bash, dash, ash, ksh.
6. Performance: dash vs bash
dash is faster than bash for trivial scripts. The startup time is ~5x faster on cold start, and many internal operations (variable expansion, loops) are 2–3x faster. This is why Debian switched /bin/sh to dash — boot scripts are run hundreds of times during init, and bash’s startup overhead added measurable seconds.
For your own scripts, the perf difference is typically irrelevant unless:
- You’re running the script thousands of times (in a tight loop, per-request).
- You’re booting an embedded device where every millisecond counts.
- You’re doing batch processing where the inner loop runs 10⁶ times.
In those cases, profile first (next lesson). Don’t switch to dash on performance grounds without measuring.
6.1 Cold-start benchmark
# Trivial script: print "hi"
echo '#!/bin/sh' > /tmp/hi.sh
echo 'echo hi' >> /tmp/hi.sh
chmod +x /tmp/hi.sh
# Time 1000 invocations:
time for i in $(seq 1 1000); do /tmp/hi.sh > /dev/null; done
# On Linux, expect:
# bash: ~3.0s (3ms per invocation)
# dash: ~0.6s (0.6ms per invocation)
For a script that runs continuously (a long-running daemon), startup time is irrelevant. For one called per HTTP request, it’s significant.
7. Common pitfalls in POSIX scripts
7.1 The local trap on non-bash
foo() {
local i=1 # Works on dash, ash, busybox; FAILS on rare strict shells
}
For maximum portability, use the subshell form. In practice, local works on every shell you’ll encounter as a /bin/sh target.
7.2 Aliases don’t work in scripts
alias mygrep='grep --color=never' # No effect on script execution
mygrep foo file # Runs as 'mygrep' — not found
Aliases are an interactive feature. Scripts ignore them. Use a function or a variable instead:
mygrep() { grep --color=never "$@"; }
7.3 function NAME syntax is a bash-ism
# Bash:
function my_func() { ... }
function my_func { ... } # without ()
# POSIX (also works in bash):
my_func() { ... }
Always use the POSIX form. The function keyword is bash-only.
7.4 read -p (prompt) is bash-only
# Bash:
read -p "Name: " name
# POSIX:
printf 'Name: '
read -r name
7.5 select menu is bash-only
# Bash:
select choice in alpha beta gamma; do
echo "$choice"; break
done
# POSIX — must implement manually:
echo "1) alpha"; echo "2) beta"; echo "3) gamma"
printf "Choice: "
read -r n
case "$n" in
1) choice=alpha ;;
2) choice=beta ;;
3) choice=gamma ;;
esac
7.6 dirname/basename on edge cases
POSIX dirname and basename exist but have surprising behaviour on empty input:
dirname "" # → "."
basename "" # → ""
dirname "/" # → "/"
basename "/" # → "/"
dirname "/foo" # → "/"
basename "/foo/" # → "foo" (trailing slash stripped)
If you need to be paranoid:
[ -n "$path" ] || { echo "empty path" >&2; exit 1; }
dir=$(dirname "$path")
8. A real-world bisection: porting a bash script to POSIX
Suppose you have a bash script and need to make it run under dash. Here’s the bisection procedure:
8.1 Step 1: Run with dash
dash myscript.sh 2>&1 | head -20
You’ll get errors. Each error points to a bash-ism:
myscript.sh: 4: [[: not found
myscript.sh: 8: Bad substitution
myscript.sh: 12: Syntax error: "(" unexpected
8.2 Step 2: shellcheck -s sh
Catches the static issues even before running:
shellcheck -s sh myscript.sh
8.3 Step 3: Replace per category
In order of impact:
[[ ]]→[ ]: usually mechanical. Add quoting; replace==with=; rewrite=~asgrep -Eq.(arr=( ))→set --or temp files: requires real redesign.local→ subshell()if strictly POSIX.<()→ temp files.echo -e/-n→printf.$RANDOM→awk 'BEGIN{srand();print int(rand()*32768)}'.$(< file)→$(cat file).
After each batch of changes, re-run dash myscript.sh and shellcheck -s sh.
8.4 Step 4: CI lock-in
Add CI to prevent regression:
# .github/workflows/posix.yml
name: posix-portability
on: [push, pull_request]
jobs:
posix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: shellcheck -s sh myscript.sh
- run: |
sudo apt-get install -y devscripts dash
checkbashisms myscript.sh
dash -n myscript.sh # Syntax check, don't execute
- run: |
docker run --rm -v $PWD:/w -w /w busybox sh -n myscript.sh
-n is “no-execute, syntax check only” — POSIX standard, works in every shell. Combined with shellcheck and checkbashisms, you have rigorous CI for POSIX-ness.
9. The portable preamble — your reusable template
Drop this at the top of any #!/bin/sh script:
#!/bin/sh
# myscript - <one-line purpose>
# Portable: runs under dash, ash, busybox sh, bash, ksh.
set -eu
# Required environment:
: "${MYAPP_HOME:?MYAPP_HOME must be set}"
# Pin environment:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH
TZ=UTC
export TZ
LC_ALL=C
export LC_ALL
# Pipefail if available (bash, ksh; not POSIX, gracefully ignored on dash):
( set -o pipefail 2>/dev/null ) && set -o pipefail || true
# Cleanup trap (POSIX traps are limited but EXIT works everywhere):
TMPDIR=$(mktemp -d -t myscript.XXXXXX)
trap 'rm -rf "$TMPDIR"' EXIT INT TERM
# Logging helpers:
log() { printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >&2; }
die() { log "FATAL: $*"; exit 1; }
warn() { log "WARN: $*"; }
This is the POSIX equivalent of the strict-mode preamble we’ve been using throughout the course. Same protective intent, different syntax.
9.1 What set -e does in dash vs bash
set -e (errexit) has subtle differences across shells. The POSIX rules are:
- A simple command that fails outside a conditional → script exits.
- A command in
if,&&,||,!does NOT trigger errexit. - A pipeline triggers based on the last command’s exit (without
pipefail).
Where bash differs from dash:
- bash with
inherit_errexitpropagates-einto command substitution; dash does not. - bash inside functions:
-ecarried in by default in modern bash (4.4+).
In practice, write your script defensively (check return codes explicitly when you care) and don’t rely on set -e doing exactly what bash does.
Going deeper
You now have the catalogue of bash-isms and their swaps. The advanced skill is knowing where the tools and mental models lie to you — the places where a “portable” script passes every check you ran and still breaks in production. Everything below is verified on this host (bash 3.2 + /bin/dash, arm64 macOS); the Bad substitution and not found errors are real output.
The false-confidence trap: bash --posix is not dash
The most dangerous way to “test for portability” is to run your script through the /bin/sh on a box where /bin/sh happens to be bash — Red Hat, Fedora, most macOS setups. Invoking bash as sh, or bash --posix, turns on POSIX mode, and the natural assumption is that POSIX mode strips out the bash-isms. It does not. Verified:
$ bash --posix -c '[[ 1 == 1 ]] && echo yes'
yes # [[ ]] STILL works
$ bash --posix -c 'a=(1 2 3); echo "${a[2]}"'
3 # arrays STILL work
$ bash --posix -c 'grep hi <<< "hi there"'
hi there # here-strings STILL work
$ bash --posix -c 'echo x{A,B}'
xA xB # brace expansion STILL works
$ bash --posix -c 'cat <(echo hi)'
bash: syntax error near unexpected token `(' # process substitution IS disabled
POSIX mode tightens dozens of conformance details (how set -e behaves, how assignment errors are reported, which reserved words apply) and disables a few syntax features like process substitution — but it deliberately keeps [[ ]], arrays, here-strings, and brace expansion, because bash is still bash underneath. So a “POSIX” script that uses arrays runs perfectly under Red Hat’s /bin/sh and then explodes the first time it lands on Debian’s dash or in an Alpine container. Verified contrast on the same host: dash -c '[[ 1 == 1 ]]' → [[: not found.
The lesson: bash-as-sh is not a portability test. The only trustworthy runtime checks are the shells you’ll actually deploy to — real dash, real busybox sh, real ksh — plus the static tools (shellcheck -s sh, checkbashisms), which reason about the code rather than executing whatever branch you happened to hit.
“Bad substitution” is a runtime failure, not a syntax one
The parameter-expansion bash-isms are especially nasty because they fail at the moment the expansion runs, not when the script is parsed. Under dash and busybox, every one of these aborts:
$ dash -c 'v=ABC; echo "${v,,}"' # lowercase
dash: 1: Bad substitution
$ dash -c 'v=abc; echo "${v^^}"' # uppercase
dash: 1: Bad substitution
$ dash -c 'v=foofoo; echo "${v//foo/bar}"' # global replace
dash: 1: Bad substitution
$ dash -c 'v=abcdef; echo "${v:2:3}"' # substring
dash: 1: Bad substitution
Because the failure is at expansion time, a bash-ism buried in a rarely-taken branch — an error handler, a --verbose path, a cleanup routine — can pass every CI run and then take down the one production invocation that finally reaches it. That is exactly why shellcheck -s sh (which reasons about the code statically) is worth more than “it worked when I tried it”: the linter sees the branch you never exercised. A dash -n script.sh catches the syntactic bash-isms (arr=(...), [[, <()) without running a line — verified: dash -n on a file containing arr=(1 2 3) prints Syntax error: "(" unexpected and exits non-zero — but expansion bash-isms like ${v//} are only caught by shellcheck or by actually executing the line.
The POSIX-2024 mirage: paper standard vs shipped shells
You’ll read that some former bash-isms “became POSIX.” POSIX Issue 8 (IEEE Std 1003.1-2024) did fold a number of long-standing extensions into the written standard. But the standard is a document, not a shell. The dash on your Debian box and the busybox in your router implement the older Issue 7 vocabulary, and they will for years. Concretely — whatever the 2024 text says about ${var//pattern/string}, running it under the dash that Debian ships today still gives (verified):
$ dash -c 'v=foofoo; echo "${v//foo/bar}"'
dash: 1: Bad substitution
So “it’s POSIX now” is a claim about paper conformance, not about the machines your script will meet. Portability is defined by the oldest, smallest shell in your deployment matrix, not by the latest standard. Treat the freshly-standardised features as bash-isms until the shells you actually ship on grow them.
local — the bash-ism you can (almost) always get away with
local is not in POSIX, yet dash, busybox ash, and ksh all implement it as an extension (verified: dash -c 'f(){ local x=1; echo "$x"; }; f' prints 1). In practice you can use local in a #!/bin/sh script and it works on essentially every /bin/sh you’ll meet in modern DevOps. Two caveats keep it honest:
- Truly minimal shells lack it. Debian’s old
posh(deliberately strict POSIX) and some hand-built busybox configs have nolocal. If your contract is “runs on any conforming POSIX shell,” use the subshell-function formf() ( ... )instead — assignments in a subshell can’t leak out, so they are local by construction. local x=$(cmd)swallows the exit status — in every shell, bash included.localis itself a command that succeeds, so it masks the non-zero status of the command substitution, andset -enever fires. ShellCheck flags this as SC2155. Split the declaration from the assignment whenever the value can fail:
local x # declare (POSIX-extension, or omit under strict POSIX)
x=$(cmd) || return 1 # assign — and now the failure is actually visible
echo is doubly broken in POSIX — the verified proof
The rule “never use echo in scripts, use printf” is not pedantry; echo’s behaviour genuinely forks across shells. Under dash, whose echo follows the XSI convention, echo -e "a\tb" does two surprising things at once (verified):
$ dash -c 'echo -e "a\tb"'
-e a b
The -e is printed literally — dash’s echo has no -e flag — and the \t is expanded to a real tab anyway, because XSI echo interprets backslash escapes by default. So the exact line that suppresses-and-escapes under bash prints-and-escapes under dash, silently corrupting generated config files, field delimiters, and log output. printf has one behaviour everywhere:
printf 'a\tb\n' # tab then newline — identical on bash, dash, busybox, ksh
printf '%s\n' "$var" # the ONLY safe way to print an arbitrary variable
printf '%s\n' "$x" is also the only safe way to print an unknown value: echo "$x" mangles anything that looks like a flag, e.g. a variable holding -n or -e.
set -e is weaker in POSIX than you think
Section 9.1 noted set -e differs across shells; the practical consequence for portable code is blunt: do not lean on set -e for correctness in a #!/bin/sh script. dash lacks inherit_errexit, so X=$(failing_cmd) sails straight past; it lacks set -o pipefail entirely (verified: dash -c 'set -o pipefail' → set: Illegal option -o pipefail), so a broken pipeline reports success; and the rules for whether -e fires inside a function called from a conditional vary between shells. Portable scripts check the return codes that matter explicitly:
if ! cp "$src" "$dst"; then
printf 'copy failed: %s -> %s\n' "$src" "$dst" >&2
exit 1
fi
Keep set -eu at the top as a backstop, but treat it as a seatbelt, not a substitute for looking where you’re going.
“busybox sh” is not one shell
busybox sh is a compile-time-configurable applet: whether it supports local, arithmetic for, or certain expansions depends on the CONFIG_ASH_* flags chosen when that busybox binary was built. The busybox in Alpine’s default image, the one baked into an OpenWrt firmware, and the one in a scratch container can differ in what they accept. This is why the final line of defence is running your script under the specific busybox you deploy —
docker run --rm -v "$PWD:/w" -w /w busybox sh -n script.sh
— not “a” busybox. When you can’t pin the exact build, hold to the strict POSIX core and it will survive across all of them. The same logic applies to ksh (AIX/Solaris /bin/sh heritage): it adds features like [[ ]] and arrays (verified: ksh -c '[[ 1 == 1 ]] && echo ok' prints ok), so a script that runs on ksh may still be full of bash-isms that die on dash. “Runs on my shell” never implies “is POSIX.”
10. Quick reference card
Shebang choice
#!/bin/sh # POSIX. Portable. Required for init/postinst/cloud-init.
#!/usr/bin/env bash # Bash. Default for everything else.
POSIX has
[ ] # test
case ... in pattern) ;; esac # multi-way string match
$(( )) # arithmetic
$( ) # command substitution
$@, $*, $# # positional params (your only "array")
${var}, ${var:-x}, ${var:?x} # parameter expansion (basic)
${var#prefix}, ${var%suffix} # prefix/suffix strip
printf 'fmt' args # ALWAYS use this, never echo
trap 'cmd' EXIT INT TERM HUP
set -e, set -u
function() { ... } # only ()-form
local # works in dash/ash, NOT strict POSIX
POSIX does NOT have
[[ ]] # use [ ] + case
arr=( ) # use $@ or files
${var,,}, ${var^^} # use tr
${var:offset:len} # use cut
local (officially) # use ()-form function or just don't
<(cmd), >(cmd) # use temp files
<<< # use heredoc or pipe
echo -e, echo -n # use printf
$RANDOM, $EPOCHSECONDS, $BASHPID
trap '...' ERR / DEBUG
function NAME { ... } # use NAME() { ... }
select # write your own menu
read -p, read -d # use printf + read
Detection commands
shellcheck -s sh script.sh # static analysis
checkbashisms script.sh # Debian's stricter tool
dash -n script.sh # syntax check
docker run --rm -v $PWD:/w -w /w busybox sh -n script.sh # busybox check
The 6 commandments of portable shell
#!/bin/shmeans POSIX, period. Bash-isms break it.- Always quote:
[ -f "$f" ]— POSIX[ ]word-splits aggressively. printfalways,echonever.- Use
casefor pattern matching — it’s[[ ]]'s portable substitute. - Validate inputs to
evalif you must use it for dynamic vars. - CI with
shellcheck -s sh+dash -n+ busybox sh to lock portability in.
Practice challenges
Six exercises, escalating from “spot the bash-ism” to “port a real function.” Try each before opening the solution. If you have dash installed (sudo apt install dash, or it’s already your /bin/sh on Debian), run your answers under it — dash yourscript.sh — to feel the difference between “works on my bash” and “actually portable.”
1. Spot every bash-ism (beginner). This snippet has a #!/bin/sh shebang but three bash-isms. Name each one and predict the error dash gives.
#!/bin/sh
if [[ -n $NAME ]]; then
echo -e "Hello, ${NAME^}\n"
fi
<details> <summary>Solution</summary>
Three bash-isms: (1) [[ ]] → dash says [[: not found; (2) ${NAME^} capitalise-first → Bad substitution; (3) echo -e → dash prints -e literally and expands \n. Portable rewrite:
#!/bin/sh
if [ -n "$NAME" ]; then
printf 'Hello, %s\n\n' "$NAME" # (drop the ^ or capitalise with sed/tr if needed)
fi
Why: each is a bash extension absent from the POSIX phrasebook — and note that once [[ ]] becomes [ ], quoting "$NAME" is now mandatory, because [ ] word-splits.
</details>
2. Convert a regex test to POSIX (beginner → intermediate). Rewrite [[ $input =~ ^[0-9]+$ ]] (is $input all digits?) without bash. Give both a grep version and a fork-free pure-shell version.
<details> <summary>Solution</summary>
# Version A — grep (clear, spawns one process):
if printf '%s' "$input" | grep -Eq '^[0-9]+$'; then echo "all digits"; fi
# Version B — pure shell, no fork (fast in loops):
case "$input" in
''|*[!0-9]*) echo "not all digits" ;;
*) echo "all digits" ;;
esac
Why: =~ is a bash-only operator; case with the negated bracket class *[!0-9]* means “contains at least one non-digit,” and the leading '' rejects the empty string — verified under dash: 42→all-digits, 4a2→not, empty→not.
</details>
3. Replace an array (intermediate). Port this loop to POSIX:
files=(access.log error.log debug.log)
for f in "${files[@]}"; do process "$f"; done
<details> <summary>Solution</summary>
set -- access.log error.log debug.log
for f in "$@"; do process "$f"; done
Why: $@ (the positional parameters, loaded by set --) is POSIX’s only array; "$@" expands to each element as a separate quoted word, exactly like "${files[@]}". Need to append? set -- "$@" new.log. Need the count? $#.
</details>
4. A POSIX “associative array” (intermediate → advanced). Implement set_attr KEY VALUE and get_attr KEY without bash’s declare -A, and make it injection-safe.
<details> <summary>Solution</summary>
set_attr() {
case $1 in
[A-Za-z_][A-Za-z0-9_]*) eval "ATTR_$1=\$2" ;; # validated key only
*) printf 'invalid key: %s\n' "$1" >&2; return 1 ;;
esac
}
get_attr() { eval "printf '%s' \"\${ATTR_$1:-}\""; }
set_attr name alice
set_attr age 30
printf 'name=%s age=%s\n' "$(get_attr name)" "$(get_attr age)" # name=alice age=30
Why: POSIX has no associative arrays, so you synthesise them by building dynamic variable names (ATTR_name, ATTR_age) with eval. The case guard is not optional — an unvalidated key like x=$(rm -rf /) inside eval is a command-injection hole. Verified under dash. (For untrusted keys, prefer the file/awk pattern from §5.5.)
</details>
5. Prove portability in CI without five machines (advanced). Write the four commands that give high confidence a script.sh is POSIX, and say what each one catches that the others miss.
<details> <summary>Solution</summary>
shellcheck -s sh script.sh # 1
checkbashisms script.sh # 2
dash -n script.sh # 3
docker run --rm -v "$PWD:/w" -w /w busybox sh -n script.sh # 4
Why: (1) shellcheck -s sh is static and sees bash-isms in branches you never execute — including expansion bash-isms like ${v//}; (2) checkbashisms is stricter on borderline Debian-policy cases shellcheck lets slide; (3) dash -n syntax-checks the smallest common real shell without executing (so no side effects); (4) busybox sh -n covers the embedded/Alpine target, which can differ from dash. Layer all four and treat any finding as a build failure.
</details>
6. Port a real function (advanced → expert). Make this dash-safe. It uses three bash-isms — find them, then rewrite.
count_uniq() {
local file=$1
mapfile -t lines < <(sort -u "$file")
printf '%s\n' "${#lines[@]}"
}
<details> <summary>Solution</summary>
Bash-isms: local (extension, tolerated but flagged), mapfile (bash-only), and <(...) process substitution (bash-only). Don’t translate them one-for-one — redesign to stream:
count_uniq() {
# subshell-form function → any assignment is automatically local
sort -u "$1" | wc -l | tr -d ' '
}
count_uniq /etc/hosts # e.g. 12
Why: the bash version slurps the whole file into an array just to count it; the portable version streams sort -u | wc -l and never materialises an array — simpler and POSIX. The subshell () body removes the need for local, and tr -d ' ' trims wc’s leading padding. Verified under dash.
</details>
Common beginner mistakes
“#!/bin/sh and #!/bin/bash are the same thing.” They select different languages. On Debian/Ubuntu /bin/sh is dash, on Alpine/OpenWrt it’s busybox ash — neither is bash. The shebang chooses your vocabulary for the whole file, not just “a shell.” Right model: #!/bin/sh is a promise you have to keep.
“It ran on my Mac (or Red Hat box) with sh, so it’s portable.” Those /bin/sh are bash in POSIX mode, which still accepts [[ ]], arrays, here-strings, and brace expansion (verified). They pass bash-isms you think you removed. Right model: the only honest runtime tests are real dash and real busybox sh — plus static shellcheck -s sh / checkbashisms.
“== works in [ ].” It works in bash’s [, so you never noticed. POSIX [ wants a single =; dash gives [: a: unexpected operator (verified). Right model: = for string equality in [ ]; == only inside bash’s [[ ]].
“POSIX-2024 standardised ${var//}, so I can use it in /bin/sh.” The paper standard is not the shell on the box — the dash Debian ships today still answers Bad substitution (verified). Right model: portability is set by the oldest, smallest shell you deploy to, not by the newest spec.
“I’ll just be careful with quoting — [[ ]] and [ ] are basically the same.” [[ ]] suppresses word-splitting and globbing; [ ] does neither. [ -f $file ] with a space in $file becomes [ -f a b ] and errors. Right model: inside [ ], quote everything — [ -f "$file" ] — every time.
“echo -e gives me tabs, that’s portable enough.” echo is the least portable builtin in the language; dash prints -e literally while also expanding \t, so your output is wrong in two ways at once. Right model: printf for every non-trivial output, and printf '%s\n' "$x" to print any variable safely.
“local isn’t POSIX, so I can never use it in sh scripts.” Over-correction. dash, busybox ash, and ksh all implement local as an extension (verified), so it’s fine on every /bin/sh you’ll realistically meet. Right model: use local freely unless your contract is strict-any-POSIX-shell; only then drop to the subshell-function form. (And beware local x=$(cmd) masking the exit status — SC2155 — in every shell.)
Glossary
- POSIX — the portable-shell standard (IEEE Std 1003.1). Defines the common language every conforming shell must implement. The “phrasebook.”
- POSIX
sh— the shell language POSIX specifies. An abstract standard; the concrete shells that implement it are dash, ash, busybox sh, ksh, and bash-in-POSIX-mode. - bash-ism — any feature bash adds beyond POSIX (
[[ ]], arrays,${var,,},<(),$'...',local,mapfile, …). Harmless in a bash script; a bug in a#!/bin/shone. - dash — the Debian Almquist Shell; Debian/Ubuntu’s
/bin/shsince ~2006. Small, fast, strict POSIX. The de-facto reference for “is this portable?” - ash — the Almquist Shell; the lineage dash and busybox’s shell descend from. Alpine’s
/bin/shis busybox ash. - busybox — a single binary bundling tiny versions of many utilities (including a POSIX-ish
sh). The shell on Alpine, OpenWrt, Yocto, and most embedded Linux. Compile-time configurable, so two busybox builds can differ. - ksh — the KornShell; historic
/bin/shon AIX and some Solaris/BSD. Where bash borrowed[[ ]]and arrays, so it accepts many “bash-isms” — which does not make them POSIX. - shebang — the
#!/path/to/interpreterfirst line that tells the kernel which interpreter runs the file.#!/bin/shvs#!/usr/bin/env bashis the single most consequential choice for portability. - POSIX mode — the restricted behaviour bash enters when invoked as
shor with--posix. Tightens conformance details and drops a few features (e.g. process substitution) but keeps[[ ]], arrays, and here-strings — so it is not a real POSIX shell for testing. [ ](test) — the POSIX conditional command (also spelledtest). Requires spaces around the brackets and quoting of variables; uses=(not==) for strings and-eq/-lt/… for numbers.case— POSIX multi-way pattern match. The portable substitute for bash’s[[ $x == pattern ]]; supports*,?, and[abc]globs, first-match-wins.- parameter expansion — the
${var...}family.${var#pfx},${var%sfx},${var:-x},${var:=x},${var:+x},${var:?x}are POSIX;${var,,},${var^^},${var//},${var:off:len}are bash-only. - positional parameters —
$1 $2 … $@ $* $#. In POSIX these are the only array;set -- a b cloads them. - process substitution — bash’s
<(cmd)/>(cmd), which present a command’s output as a filename. Not POSIX; replace with temp files or pipes. - here-string (
<<<) — bash shorthand feeding a string to stdin. Not POSIX; use a heredoc (<<EOF, which is POSIX) orprintf ... | cmd. - ANSI-C quoting (
$'...') — bash syntax where$'\t'becomes a tab. Not POSIX; dash treats$'...'literally. Useprintfto emit control characters. command -v— the POSIX way to test whether a command exists (command -v jq >/dev/null). Portable replacement for bash-specifictype -P/hash.printf— the portable output builtin. Unlikeecho, its escape handling and flag behaviour are identical across shells.printf '%s\n' "$x"is the safe way to print any value.- errexit /
set -e— “exit on first error.” Present in all shells but with subtly different semantics; weaker in dash (nopipefail, noinherit_errexit), so don’t rely on it for correctness in portable code. - pipefail —
set -o pipefailmakes a pipeline fail if any stage fails. A bash/ksh feature; dash rejects it (Illegal option). Guard it behind a bash check or drop it in/bin/shscripts. shellcheck -s sh— runs the ShellCheck linter in POSIX mode; flags bash-isms as SC2039 and friends, statically, including in branches you don’t execute.checkbashisms— Debian’s stricter bash-ism detector (in thedevscriptspackage); the canonical gate for Debian’s “must run under dash” policy.dash -n/sh -n— parse-and-syntax-check a script without executing it (-n= no-exec). Catches syntactic bash-isms with zero side effects; ideal for CI.- initramfs — the minimal in-memory root filesystem the kernel mounts at early boot. Often has no bash, which is one reason init and recovery scripts must be POSIX.
- cloud-init — the standard cloud-instance init system. Its raw-shell
bootcmd/runcmdhooks run before extra packages install, so they must be POSIXsh.
11. Wrap-up
The POSIX-vs-bash decision is binary at the file level (you pick one shebang) but cumulative across your codebase. Most teams should default to bash and use POSIX only where the deployment target genuinely requires it — package post-install scripts, cloud-init, alpine ENTRYPOINTs, embedded-Linux contexts.
When you do drop to POSIX, the discipline is consistent:
- Quote everything in
[ ]. - Replace bash-isms with their POSIX equivalents (which often exist, sometimes uglier).
- Use
caseinstead of[[ ]]for pattern matching. - Use
$@as your only array. - Use
printfexclusively. - Run
shellcheck -s sh+checkbashisms+dash -nin CI.
The payoff: scripts that survive the next 20 years of distro changes, the next container minimization, the next “we need this to run on the rescue image.” Pure POSIX has a long shelf life. Bash-isms expire the moment your deployment target shifts.
Next: L24 — performance and profiling. We’ll measure where shell scripts spend their time, when fork/exec overhead dominates, and the practical “you’ve crossed the line — rewrite this in Python or Go” thresholds.