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.
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.”
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.
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.
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.
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.
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.