Skip to content
shellhacks.dev
Shell Scripting

Portable Shell Scripts and POSIX sh Versus Bash in Practice

Write portable shell scripts that run on dash, BusyBox and Bash: what POSIX sh allows, which Bashisms to drop, and how to test on each shell.

35 min read
Summarize in AI
Illustration for "Portable Shell Scripts and POSIX sh Versus Bash in Practice": a terminal session, a table of open ports, file permission bits.

A portable shell script is one whose behavior does not depend on which program answers to the name sh. The practical way to get there is to write to the POSIX shell language, test on at least two real shells that are not Bash, and treat every Bash feature as a deliberate choice instead of a habit. The examples here ran on Debian 12 with dash 0.5.12 and Bash 5.2, and on Alpine 3.20 with BusyBox 1.36 ash. If the script only ever runs where Bash is installed, the Bash scripting tutorial is the better guide, and this one explains when to pick which.

What sh is on your machine

The name sh is a contract, not a program. It promises a shell that implements the POSIX shell language, and each system fills it with whatever it prefers. Check yours before assuming anything.

Shell
ls -l /bin/sh
readlink -f /bin/sh
Text
lrwxrwxrwx 1 root root 4 Mar  1 09:12 /bin/sh -> dash
/usr/bin/dash

On Debian and Ubuntu it is dash, chosen in 2006 because it starts faster and uses less memory than Bash, which matters for boot scripts. On Alpine and other BusyBox systems it is ash, a close relative of dash. On Fedora, Arch and macOS it is Bash, started in POSIX mode. On FreeBSD it is a small Almquist shell. A script developed and checked on a Fedora laptop with #!/bin/sh can use Bash features by accident and then fail the moment it lands on a Debian server. This is the single largest source of the familiar complaint that it works locally in shell scripting.

What POSIX actually specifies

The shell language is defined in the Shell Command Language chapter of POSIX.1-2017, and the utilities scripts call (sed, awk, grep, sort, find) are specified in the same document. It covers pipelines, lists, if, for, while, until, case, functions with name() { ...; }, command and arithmetic substitution, here documents, parameter expansion with the :-, :=, :?, :+, #, ##, % and %% operators, the special parameters, redirections, trap, getopts, and a list of special builtins.

What it leaves out is the long list that Bash added: arrays, associative arrays, [[, process substitution, brace expansion, ${var//a/b} replacement, substring offsets, the local keyword (not in the standard, though nearly every shell supports it), source, pipefail and $RANDOM. The standard is the lowest common denominator, and writing to it is a discipline of knowing which of your habits sit above the line.

Choose the interpreter on purpose

Pick one of three declarations and mean it. #!/bin/sh says "POSIX only, and run me with whatever sh is here." #!/usr/bin/env bash says "Bash is required." #!/bin/bash says the same but assumes a path that fails on NixOS, some BSDs and macOS Homebrew setups.

SituationShebangReason
Installer or entrypoint that runs on unknown hosts#!/bin/shOnly sh is guaranteed to exist
Docker entrypoint on Alpine#!/bin/shBash is not in the base image
Dev tooling on machines you control#!/usr/bin/env bashArrays and [[ are worth it
Anything using associative arrays#!/usr/bin/env bashNo POSIX equivalent
Performance sensitive boot or init script#!/bin/shdash starts several times faster

The shebang is also a promise about the test matrix. If it says sh, the script is checked under dash and BusyBox. If it says Bash, installing Bash is a documented requirement.

Bashism: double brackets

The extended test [[ ]] is the most common Bashism. In dash it fails with [[: not found. The POSIX replacement is the [ command, with every variable quoted, plus case for pattern matching.

Shell
#!/bin/sh
# Bash: [[ $name == web-* && -n $env ]]
case $name in
  web-*) pattern_ok=1 ;;
  *)     pattern_ok=0 ;;
esac
if [ "$pattern_ok" = 1 ] && [ -n "$env" ]; then
  echo "matched"
fi

Inside [ ], use a single = for string equality. The double == works in Bash's [ and in some other shells but is not in POSIX, and dash rejects it with unexpected operator. Replace -a and -o by chaining two tests with && and ||, since the standard marks the former obsolescent and they are ambiguous when an operand looks like an operator. Regex matching with =~ has no POSIX form; call grep -E or expr instead.

Shell
if printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
  echo "semver"
fi

Bashism: arrays

POSIX sh has exactly one array: the positional parameters $1, $2 and so on. Everything else must be encoded. For a list of simple tokens that contain no whitespace, a string split by the shell works. For a list that may contain spaces, use positional parameters through set --, which rewrites "$@" inside the current function or script.

Shell
#!/bin/sh
set -- "web 01" "web02" "db01"
for host do
  printf '[%s]\n' "$host"
done
echo "$# hosts"
set -- "$@" "cache01"
echo "$# hosts"
Text
[web 01]
[web02]
[db01]
3 hosts
4 hosts

A for loop with no in list iterates over "$@", which is both portable and quoting-safe. The cost is that there is one such list per scope. A function that needs its own list while the script holds another one must save and restore, or restructure the work to pass items through standard input one per line. For a lookup table, a case statement inside a function replaces an associative array cleanly.

Shell
port_for() {
  case $1 in
    http)  echo 80 ;;
    https) echo 443 ;;
    ssh)   echo 22 ;;
    *)     return 1 ;;
  esac
}
port_for https

Bashism: process substitution and here strings

The forms <(cmd) and <<< "text" are Bash, Zsh and Ksh only. The portable substitute for a here string is a here document or a pipe, and for process substitution it is a temporary file or a named pipe. The loop that needs to modify variables is the only place this really hurts, because a pipe into while runs the loop in a subshell.

Shell
#!/bin/sh
tmp=$(mktemp) || exit 1
trap 'rm -f "$tmp"' EXIT
grep -v '^#' hosts.txt > "$tmp"
count=0
while IFS= read -r line; do
  count=$((count + 1))
done < "$tmp"
echo "$count hosts"

Writing to a file and redirecting from it keeps the loop in the main shell. An alternative with no temporary file is a here document that wraps command substitution, done <<EOF followed by $(command), though the whole output is held in memory and trailing newlines are trimmed. For comparing two sorted streams, diff against two temporary files is the plain version of diff <(a) <(b).

Bashism: string operations

POSIX parameter expansion gives you removal of prefixes and suffixes, defaults, and length, and nothing more. The substitution forms ${s/old/new} and ${s//old/new}, substring ${s:2:3}, case conversion ${s^^} and indirect ${!name} are all Bash extensions.

NeedBashPOSIX sh
Replace all${s//a/b}printf '%s' "$s" | sed 's/a/b/g'
Substring${s:2:3}printf '%s' "$s" | cut -c3-5
Upper case${s^^}printf '%s' "$s" | tr '[:lower:]' '[:upper:]'
Starts with[[ $s == pre* ]]case $s in pre*) ... ;; esac
Strip extension${f%.*}Same, and portable
Basename${f##*/}Same, and portable

The cost of the portable forms is a subprocess per call, so inside a tight loop over thousands of items they are noticeably slower. When that matters, move the heavy lifting into one sed or awk invocation that handles the whole input, rather than a fork per line. The shape of those tools is described in the grep, sed and awk guide.

Bashism: echo and printf

This one produces silent bugs, not errors. In POSIX sh, echo with a backslash sequence is implementation-defined. Dash interprets \n in echo by default, Bash does not unless asked, and -e and -n are not guaranteed to be treated as options.

Shell
sh -c 'echo "a\tb"'
bash -c 'echo "a\tb"'
sh -c 'echo -e "x"'
Text
a       b
a\tb
-e x

The three results differ for the same text. The fix is mechanical: use printf, which the standard defines precisely. Always give printf a format string and pass data as arguments, so that a percent sign or backslash in the data is not interpreted.

Shell
printf '%s\n' "$message"
printf 'name=%s\tport=%s\n' "$name" "$port"
printf %s "no trailing newline"

The pattern printf "$message" with data in the format is a bug and a small injection risk. See the shell script security guide for why format strings and option-like data need the same care as quoting.

Bashism: local, source and function syntax

Three keywords sit in a gray area. The function keyword, as in function check { ...; }, is Bash and Ksh; POSIX only defines check() { ...; }. The word source is Bash and Zsh; POSIX defines the dot command, . ./lib.sh, and that is the portable spelling. The local keyword is not in POSIX.1-2017 but is implemented by dash, BusyBox ash, Bash, Ksh93 and Zsh, so it is safe in practice if you restrict yourself to local name or local name=value with a single simple variable.

Shell
#!/bin/sh
. "$(dirname "$0")/lib.sh"

greet() {
  local who="$1"
  printf 'hello %s\n' "$who"
}
greet world

The restriction matters because the shells differ at the edges. In dash, local x=$(cmd) works, but in Bash it hides the exit status of cmd; declare and assign on separate lines when the status matters. Do not combine local with arrays, -r or -i, none of which are portable. If you need strictly standard behavior, avoid local and prefix variable names with the function name.

Bashism: pipefail, ERR and other settings

set -o pipefail is the setting people miss most. It was added to the POSIX standard text only in later revisions of Issue 8 and is not available in dash 0.5.12, which exits with Illegal option -o pipefail. BusyBox ash accepts it; BusyBox 1.35 on Debian 12 printed ok for set -o pipefail, but check older images before relying on it. The ERR trap, BASH_SOURCE, FUNCNAME, LINENO in all contexts, $RANDOM, $SECONDS, PIPESTATUS and $OSTYPE are also Bash or Ksh extras.

Shell
#!/bin/sh
set -eu
# pipefail is not portable; guard it
if (set -o pipefail) 2>/dev/null; then
  set -o pipefail
fi

The subshell test attempts the option and discards the error, so the same script runs on dash, which skips it, and on Bash or BusyBox, which enable it. Where a pipeline failure matters and pipefail is missing, break the pipeline into steps that write to a temporary file and check each one's status. The settings -e and -u are in POSIX and behave the same across shells apart from the corner cases in the next section.

Where set -e differs between shells

The rules for when set -e exits are written down in POSIX, but implementations disagree in the odd places, which makes the setting a poor foundation for portability. A well-known example is a function used as a condition, where the errexit rule is suspended for everything the function does.

Shell
#!/bin/sh
set -e
check() {
  false
  echo "still running inside the condition"
}
if check; then echo "check passed"; fi
Text
still running inside the condition
check passed

Both dash and Bash print the same here, which surprises readers who expected the false to abort the function. A difference appears in compound commands: an assignment from a failing command substitution exits on some shells and not on others in specific forms, and local x=$(false) never exits because local succeeds. The reliable approach is to check the commands that matter with || exit or || return, and use set -e as a backstop.

Arithmetic

POSIX supports $(( ... )) with integer operators, comparison, ternary and the usual assignment forms in the expression, and nothing else. There is no (( )) command, no let, no ++ or -- as required operators, no ** exponent and no floating point. Dash does not implement the ** exponent that Bash does, and fails with arithmetic expression: expecting primary, so a script that squares with ** works on one and breaks on the other.

Shell
#!/bin/sh
i=0
while [ "$i" -lt 3 ]; do
  i=$((i + 1))
  printf 'pass %s\n' "$i"
done
square=$((i * i))
echo "$square"

Use [ "$a" -lt "$b" ] or [ $((a < b)) -eq 1 ] for comparisons, and hand decimals to awk, which is in POSIX. For integers beyond 64 bits, no shell helps, and bc or dc is the portable choice. Hexadecimal and octal constants work in $(( )) in all POSIX shells, so $((0x1f)) is fine.

Functions, return values and exit codes

Functions in POSIX sh return a status with return n and output data on standard output. There are no name references, so a function cannot assign to a caller's variable by name without eval. The usual substitutes are to print the result and capture it with command substitution, or to set a well-named global variable and document it.

Shell
#!/bin/sh
parse_host() { printf '%s\n' "${1%%:*}"; }
parse_port() { printf '%s\n' "${1##*:}"; }

target="db01.internal:5432"
host=$(parse_host "$target")
port=$(parse_port "$target")
echo "$host $port"

Remember that command substitution creates a subshell, which costs a fork each time. In a loop over many items, it is faster to set a global in the function and read it afterward. Choose by measuring; a fork is on the order of a millisecond on a laptop and the difference rarely matters below a few thousand calls.

Read input portably

The POSIX read has only -r. It does not have -p for a prompt, -s for silent, -t for a timeout, -a for an array or -n to read a number of characters. Print the prompt with printf and use stty around read to hide a typed secret.

Shell
#!/bin/sh
printf 'Token: ' >&2
stty -echo 2>/dev/null
trap 'stty echo 2>/dev/null' EXIT
IFS= read -r token
stty echo 2>/dev/null
printf '\n' >&2

The trap restores the terminal if the user hits Ctrl-C mid-entry, which is the classic way to leave a shell with echo switched off. A timeout has no clean portable form; use the timeout utility from GNU coreutils or BusyBox, which exists on nearly every system but is not in POSIX.1-2017.

Utilities differ more than shells do

The shell is only half of portability. The commands a script calls vary between GNU, BSD and BusyBox, and these differences cause more real failures than syntax. GNU sed -i edits in place with no suffix; BSD and macOS sed -i require a suffix argument, so sed -i 's/a/b/' f fails on macOS. GNU date -d parses dates; BSD uses -j -f or -v. GNU readlink -f resolves paths; macOS before 12.3 had no -f. BusyBox grep may lack -P, -o or --include, and BusyBox find lacks -printf in minimal builds.

TaskGNU-only formPortable form
In-place editsed -i 's/a/b/' fWrite to a temp file, then mv it back
Absolute pathreadlink -f xcd -- "$(dirname -- x)" && pwd
File sizestat -c %s fwc -c < f
Extended regexsed -rsed -E (POSIX since 2024)
Null-separated findfind -print0 | xargs -0find ... -exec cmd {} +
Case-insensitive grepgrep -iSame, and portable

The habit that pays off is to read the POSIX page for any utility option you are unsure of, since an option absent from the page is a flag to test elsewhere. The POSIX utilities index lists them all.

Temporary files and cleanup

mktemp is not in POSIX.1-2017 but exists on Linux, every BSD, macOS and BusyBox, so it is safe in practice. Its options differ: GNU accepts mktemp -d and a template with trailing Xs, BSD accepts both as well, and BusyBox requires the Xs to be at the end. Use mktemp -d "${TMPDIR:-/tmp}/name.XXXXXX" with the template at the end and the trap pattern from earlier.

Shell
#!/bin/sh
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/job.XXXXXX") || exit 1
cleanup() { rm -rf "$tmpdir"; }
trap cleanup EXIT
trap 'exit 1' HUP INT TERM

POSIX trap accepts signal names without the SIG prefix and the EXIT pseudo-signal, and the handlers above work on all the shells in this guide. The ERR, DEBUG and RETURN traps do not exist in POSIX.

Test on real shells

Running a script under Bash tells you almost nothing about portability. Install the target shells and run the same script through each. In a container this is a one-liner, and a small loop turns it into a matrix.

Shell
for shell in dash "busybox sh" bash "bash --posix" ksh; do
  printf '%-14s ' "$shell"
  if $shell ./script.sh >/dev/null 2>&1; then echo ok; else echo FAIL; fi
done
Text
dash           ok
busybox sh     ok
bash           ok
bash --posix   ok
ksh            FAIL

On Debian, apt install dash busybox ksh provides all four. A Docker version runs docker run --rm -v "$PWD":/w -w /w alpine sh ./script.sh for BusyBox and docker run --rm -v "$PWD":/w -w /w debian:12 sh ./script.sh for dash. Keep bash --posix out of your confidence: it still accepts [[ and arrays, so a pass there proves nothing about dash.

Lint for portability

Two linters read scripts without running them. ShellCheck, given the shell with -s sh or inferred from the shebang, flags Bash-only syntax in a #!/bin/sh script, for example SC3010 for [[ ]], SC3054 for array references and SC3043 for local. The Debian checkbashisms script from the devscripts package is a narrower tool made for the same job and finds things ShellCheck sometimes misses, such as echo -e and $RANDOM.

Shell
shellcheck -s sh deploy.sh
checkbashisms deploy.sh
Text
In deploy.sh line 12:
if [[ -f "$cfg" ]]; then
   ^-- SC3010 (warning): In POSIX sh, [[ ]] is undefined.

The ShellCheck wiki has a page per code, with the failing example and the correction, which is the fastest way to learn a replacement. Put both linters in CI, the same way the testing guide sets up checks for scripts.

Write a portable script end to end

This installer downloads a file, verifies a checksum, and places it, using only POSIX features. It handles missing tools, picks whichever of curl and wget exists, and cleans up.

Shell
#!/bin/sh
set -eu

die() { printf 'error: %s\n' "$*" >&2; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }

url="${1:?usage: install.sh URL SHA256 [DEST]}"
want="${2:?usage: install.sh URL SHA256 [DEST]}"
dest="${3:-/usr/local/bin/tool}"

tmp=$(mktemp -d "${TMPDIR:-/tmp}/install.XXXXXX") || die "no temp dir"
trap 'rm -rf "$tmp"' EXIT
trap 'exit 1' HUP INT TERM

if have curl; then
  curl -fsSL -o "$tmp/file" "$url" || die "download failed"
elif have wget; then
  wget -q -O "$tmp/file" "$url" || die "download failed"
else
  die "need curl or wget"
fi

if have sha256sum; then
  got=$(sha256sum "$tmp/file" | cut -d' ' -f1)
elif have shasum; then
  got=$(shasum -a 256 "$tmp/file" | cut -d' ' -f1)
else
  die "need sha256sum or shasum"
fi
[ "$got" = "$want" ] || die "checksum mismatch: $got"

install -m 0755 "$tmp/file" "$dest"
printf 'installed %s\n' "$dest"
Text
$ sh install.sh https://example.org/tool.bin 9f86d081... /tmp/tool
installed /tmp/tool

Every construct appears in the POSIX specification: command -v for existence checks, parameter expansion operators, case-free branching on elif, and install, which is not strictly POSIX but is present everywhere this script is likely to run. The one deliberate non-POSIX call is mktemp, discussed above. The checksum comparison uses a single =, and every expansion is quoted.

Common portability bugs, in the order they appear

  • [[ in a #!/bin/sh script. Fails on dash with not found. Use [ ] and case.
  • == inside [ ]. Fails with unexpected operator. Use =.
  • echo -e and echo -n. Prints the flag literally on some shells. Use printf.
  • Arrays. Fail with Syntax error: "(" unexpected. Use set -- or a case.
  • source file. Fails with source: not found. Use . file.
  • function f {. Syntax error on dash. Use f() {.
  • $'...' strings. Printed literally. Use printf to build the characters.
  • &> redirection. Parsed as run-in-background then redirect. Use > file 2>&1.
  • $RANDOM. Empty in dash. Use awk 'BEGIN{srand(); print int(rand()*32768)}' or read from /dev/urandom.
  • sed -i, readlink -f, date -d. Behave differently or fail outside GNU. See the utility table above.

The &> case is the nastiest because it does not error: dash parses it as cmd & followed by > file, so the command runs in the background and its output goes to the terminal while an empty file is created.

Speed: why dash is faster

Debian switched /bin/sh to dash for boot speed. The difference is easy to measure with a loop of builtins, and the number depends on hardware, so measure on yours. In a Debian 12 container on an aarch64 machine with ten cores, 200,000 iterations of an arithmetic loop took 0.25 seconds in dash, 0.30 seconds in BusyBox ash and 0.60 seconds in Bash 5.2. Starting a shell 300 times to run a no-op took 0.12 seconds with dash, 0.13 with BusyBox and 0.18 with Bash, so a script that launches a shell thousands of times sees the saving directly.

Shell
time dash -c 'i=0; while [ $i -lt 200000 ]; do i=$((i+1)); done'
time bash -c 'i=0; while [ $i -lt 200000 ]; do i=$((i+1)); done'

Those figures come from one run of each on one machine, with the shell's own time keyword, and are only a guide. The conclusion that holds is that dash is about twice as fast as Bash for builtin-heavy loops and that neither shell is fast at string-heavy work compared to awk. Choose portability for portability, and choose speed by measuring the real script, as the debugging guide shows with strace -c and time.

Make Bash behave when it must be sh

Sometimes you inherit a script that starts with #!/bin/sh, works on a Fedora host, and breaks in production. Resist the fix of changing the shebang to Bash without understanding why, unless Bash is truly available. A quick diagnosis is to run it with dash, read the first error, and replace that one construct. Repeat until it passes. The sequence is slow once and fast thereafter, because the same dozen constructs cause most failures.

The reverse case, a Bash script run as sh script.sh by someone who typed the wrong command, is also common. A defensive header makes the failure explicit.

Shell
#!/bin/bash
[ -n "${BASH_VERSION:-}" ] || { echo "run this with bash" >&2; exit 1; }

That check uses only POSIX syntax before any Bash feature appears, so dash can parse it and report a clear message instead of a syntax error from line 40.

Containers and portability

Most portability work today happens because of images. Alpine has BusyBox ash and no Bash. Distroless images have no shell at all, which means an entrypoint script cannot exist there. Debian slim images have dash as sh and Bash installed. If your entrypoint must run in all of them, write it in POSIX sh, put exec "$@" at the end so signals reach the real process, and test it in each base image. The guide to shell scripting inside Docker covers the entrypoint pattern in depth; the short version is that a POSIX entrypoint with exec is the most portable choice there is.

Shell
#!/bin/sh
set -eu
: "${APP_ENV:=production}"
if [ "${1:-}" = "migrate" ]; then
  /app/bin/migrate
  shift
fi
exec "$@"

The leading colon is a no-op command whose arguments are expanded, which makes : "${VAR:=default}" the portable way to give a variable a default without running a command.

The shells you will meet

Knowing the family tree explains why a feature exists in one and not another. The Bourne shell, from 1979, defined the basic syntax. The Korn shell, ksh88 and later ksh93, added arrays, [[, $(( )) and $( ), and much of what POSIX standardized came from it. Bash, from the GNU project in 1989, absorbed Korn features and added its own. The Almquist shell, made for BSD in 1989, was ported to Debian as dash and forms the base of BusyBox ash. Zsh is a separate lineage that is mostly Bourne compatible but differs in word splitting and array indexing.

ShellTypical homeArrays[[localpipefail
dash 0.5.12Debian, Ubuntu /bin/shNoNoYesNo
BusyBox ash 1.35Alpine, embeddedNoOptional at build timeYesYes
Bash 5.2Most Linux distributionsYesYesYesYes
ksh93AIX, Solaris, some LinuxYesYesYes, as typesetYes
zsh 5.9macOS default login shellYes, 1-basedYesYesYes

The table is why "test on dash and BusyBox" is the working rule: together they lack nearly everything that makes a script non-portable, so a script that passes on both will almost always pass elsewhere. Zsh is the odd one. A script run by zsh on macOS because it was started from an interactive prompt still uses its shebang's shell, so the zsh default matters only for what you type interactively, not for scripts.

Quoting and word splitting are the same everywhere

One area where POSIX shells agree is the one that matters most. An unquoted expansion is split on IFS and then globbed in dash, BusyBox and Bash alike. That sameness is useful: the quoting discipline from the Bash scripting tutorial carries over unchanged. What differs is Zsh, which does not split unquoted variables by default, and Bash's nullglob, failglob and extglob options, none of which exist in POSIX.

That last difference changes how you handle a glob that may match nothing. In POSIX sh the unmatched pattern is left as written, so you test that the first match exists before looping.

Shell
#!/bin/sh
for f in /var/log/*.log; do
  [ -e "$f" ] || continue
  printf '%s\n' "$f"
done

The [ -e "$f" ] || continue line handles the unmatched case, where $f is the literal string /var/log/*.log that names no file. It costs nothing when files exist and prevents a loop body from running against a name that is not a file. The same guard protects rm, cp and chmod from acting on a literal asterisk.

Command existence: command -v, not which

A portable script checks for a tool with command -v. POSIX defines it, every shell implements it, and it finds builtins, functions, aliases and programs on PATH. which is an external program with inconsistent behavior: on some systems it prints nothing and exits 0, on others it prints a sentence to standard output when the name is missing, and on Debian it is a shell script that only searches PATH. type is acceptable but its output is not standardized. hash exists but its exit status for a missing name varies.

Shell
if command -v jq >/dev/null 2>&1; then
  echo "jq found at $(command -v jq)"
else
  echo "jq is required" >&2
  exit 127
fi

The redirections discard both the path that command -v prints and any complaint on error. Exiting with 127 mirrors the shell's own code for command not found, which makes the failure recognizable to anyone reading logs. List all required tools near the top in a loop, so a missing dependency fails immediately and not after half the work is done.

Redirection and file descriptors

POSIX specifies redirection for descriptors 0 through 9: >&3 duplicates, 3>file opens, 3<&- closes. That is enough for the techniques that matter, such as sending diagnostics to a log while keeping standard output clean. Bash adds &>, &>>, |& and {fd}> for allocating a free descriptor automatically. None of those work in dash.

Shell
#!/bin/sh
exec 3>&1
exec 1>>/var/log/job.log 2>&1
echo "this line goes to the log"
echo "this line goes to the terminal" >&3
exec 3>&-

Using exec with only redirections changes the redirections of the shell itself for the rest of the script. Save the original standard output on descriptor 3 first, as above, if some output still needs to reach the caller. The portable spelling of "both streams to a file" is always > file 2>&1, in that order.

Signals and traps

The POSIX trap accepts signal names without the SIG prefix, numbers for the standard few (1 HUP, 2 INT, 3 QUIT, 6 ABRT, 9 KILL, 14 ALRM, 15 TERM), and the pseudo-signal EXIT. Using names such as INT and TERM is fine on all the shells here, and using numbers outside that list is not portable. There is no ERR, DEBUG or RETURN trap in dash.

Shell
#!/bin/sh
cleanup() {
  status=$?
  rm -f "${lock:-}"
  exit "$status"
}
lock=/tmp/job.$$.lock
: > "$lock"
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
sleep 5

Capture $? as the first line of the handler. By the time later lines run, it holds the status of whatever ran in between. Calling exit "$status" at the end preserves the original code, which matters when the script's caller inspects it. The numbers 130 and 143 are the 128 plus signal convention, which lets a parent tell a deliberate stop from a failure.

Options with getopts

getopts is POSIX and behaves the same on every shell here, which makes it the right default for short options. The details to remember: a leading colon in the option string enables silent mode, OPTARG carries the value or the offending letter, and OPTIND must be reset to 1 if you call getopts again in the same shell, for example in a function. Long options such as --help are not supported, and the external getopt that does support them differs between GNU, BSD and BusyBox, so for portable scripts handle long options by hand.

Shell
#!/bin/sh
while [ $# -gt 0 ]; do
  case $1 in
    -h|--help)    echo "usage: tool [--out FILE] [-v] ARG..."; exit 0 ;;
    -v|--verbose) verbose=1 ;;
    -o|--out)     [ $# -ge 2 ] || { echo "--out needs a value" >&2; exit 2; }
                  out=$2; shift ;;
    --out=*)      out=${1#--out=} ;;
    --)           shift; break ;;
    -*)           echo "unknown option: $1" >&2; exit 2 ;;
    *)            break ;;
  esac
  shift
done
echo "verbose=${verbose:-0} out=${out:-none} rest=$#"
Text
$ ./tool --out=/tmp/x -v a b
verbose=1 out=/tmp/x rest=2

This hand-rolled loop uses only case, shift and parameter expansion, so it runs everywhere. The -- branch is important: it ends option parsing so that a file named -rf passed after it is treated as an argument. The shell script security guide returns to that point in the context of option smuggling.

eval, indirection and why to avoid them

Without arrays or name references, scripts reach for eval to build variable names, as in eval "val=\$$name". It works in every shell and it is an injection hole whenever any part of the string comes from outside. The portable alternatives are a case over the known names, or set -- to carry a list. Where indirection is truly needed over a fixed vocabulary, validate the name first.

Shell
get() {
  case $1 in
    [a-z_]*[!a-z_0-9]*|"") return 1 ;;
  esac
  eval "printf '%s\n' \"\${$1:-}\""
}
region=eu-west-1
get region

The guard rejects any name that contains a character outside lowercase letters, digits and underscore, so the eval only ever sees a plain identifier. Even so, this is a last resort. A script that needs it constantly has outgrown POSIX sh and should declare Bash and use declare -n or associative arrays instead.

Line endings and encodings

A script saved with Windows CRLF line endings fails in ways that look unrelated to the file. The kernel reads the shebang as #!/bin/sh\r and reports bad interpreter: No such file or directory, and when a script is run explicitly each command carries a stray carriage return, so echo hi prints hi followed by \r. Configure the editor and version control to keep scripts as LF; with Git, a .gitattributes line of *.sh text eol=lf enforces it. Check a suspect file with file script.sh, which says with CRLF line terminators, or od -c.

Text encoding matters less for the shell and more for the tools. Run text-processing in the C locale with LC_ALL=C when you want byte-wise behavior and the speed that comes with it. Sorting, character classes and case conversion all change with the locale, so a portable script sets it deliberately at the top for those steps and leaves it alone elsewhere.

Working directory and CDPATH

A portable script does not assume where it was started. Use cd -- "$(dirname -- "$0")" to anchor relative paths to the script, and remember that $0 is the path used to invoke it, which may be relative or a symlink. Two small traps lurk here. If CDPATH is set in the caller's environment, cd may print the new directory and change to an unexpected place; unset it in scripts. And cd followed by pwd returns the logical path, which may include symlinks, while pwd -P returns the physical one.

Shell
#!/bin/sh
unset CDPATH
here=$(cd -- "$(dirname -- "$0")" && pwd -P) || exit 1
cd "$here" || exit 1

On Debian 12 with CDPATH=/usr set, cd bin printed /usr/bin and changed there even from an unrelated directory, which is why scripts unset it at the top.

Exit status conventions across tools

POSIX says an exit status of 0 is success, and 1 to 125 are free for the program, 126 means not executable, 127 means not found, and anything from 128 up usually means a signal. Tools differ in what they use the small numbers for, and a portable script should not assume a particular meaning beyond zero and non-zero unless it reads the tool's page. grep and diff both use 1 for a normal outcome (no match; files differ) and 2 for trouble, so set -e treats a perfectly good "no match" as fatal unless you guard it. test uses 1 for false and 2 for a usage error.

Shell
#!/bin/sh
set -e
if grep -q ERROR app.log; then
  echo "errors found"
fi
count=$(grep -c ERROR app.log || true)
echo "$count"

Wrapping the command in an if or adding || true declares that status 1 is expected. When 2 matters too, test it explicitly: grep -q pat file; rc=$? then [ "$rc" -eq 2 ] && die "grep failed".

Migrate a Bash script to POSIX sh step by step

When a script must move from Bash to sh, do it as a sequence of small checked changes and not a rewrite. First change the shebang to #!/bin/sh and run shellcheck -s sh. The report is a to-do list ordered by line number. Second, fix the mechanical items that need no redesign: [[ to [ with quotes, == to =, source to a dot, function f { to f() {, echo -e to printf. Third, handle the structural items one at a time: arrays become positional parameters or line-oriented files, process substitution becomes a temporary file, string substitution becomes sed or case. Fourth, run the script under dash and busybox sh and compare its output with the Bash version on the same inputs.

Shell
bash old.sh sample.txt > expected.out
dash new.sh sample.txt > actual.out
busybox sh new.sh sample.txt > actual-busybox.out
cmp expected.out actual.out && cmp expected.out actual-busybox.out && echo identical

The comparison is the safety net. If you have no tests, the output of the old script on a handful of real inputs is the best specification you have, and capturing it before the first edit takes a minute. Section by section, the cost shows up in the code size: a Bash function that used an associative array and a string replacement is often forty lines in POSIX sh, and the extra length is the price of the portability.

A decision checklist

Run through these questions before writing the first line, and the shebang decides itself. Which hosts will run it, and do they all have Bash? If you cannot list them, assume they do not. Will it run as an entrypoint in a container you do not control? Then POSIX. Will it be sourced into a user's interactive shell, like a profile fragment? Then it must follow that shell's rules, not the shebang's. Does it need arrays, maps or regular expressions in conditions? Then it wants Bash, and a note in the README. Is startup time important, as in a hook that runs on every command? Then dash and builtins, with as few subprocesses as the task allows. Does it have to be understandable by a colleague in a year? Then pick the dialect that reads best for the work, document the choice, and test on what you claim.

Answering these once, in a comment at the top of the file, turns portability from a vague aspiration into a requirement that a reviewer can check. A header such as "POSIX sh, checked under dash and BusyBox" or "Bash 4.4 or newer, uses associative arrays" is more useful to the next maintainer than any amount of explanation inside the script.

Keep portability from regressing

A script that passed on dash last month can fail today because someone added a Bashism in a quick fix. The only dependable guard is automation. Add a CI job that runs ShellCheck with the shell set to sh, runs checkbashisms, and executes the script's own test suite under dash and BusyBox in containers. Fail the build on any of them. Reviewers then discuss design, and the pipeline holds the line on syntax. When a legitimate exception is needed, such as one use of local with a known safe form, record it as a ShellCheck disable directive with a reason next to the line, so the exception is visible and can be removed later.

Choose Bash anyway

Portability has a price, paid in readability and in the number of subprocesses, and the decision should be explicit. If your machines are known and all have Bash 4 or newer, use it. If the script is for developers on laptops, use Bash. If the script manipulates lists, strings and maps, use Bash. If the script is more than a few hundred lines of logic, consider a real language. A script that declares #!/usr/bin/env bash and documents the requirement is portable in the sense that matters: it either runs or says plainly why it cannot. The failure to avoid is the in-between case, where a script claims to be sh and quietly depends on Bash.

Case study: an installer that runs everywhere

The most common reason to write portable shell is an installer or bootstrap script that people run on machines you have never seen. The constraints are strict: no assumption that Bash, Python or even curl exists, no assumption about the distribution, and no tolerance for half-finished states. A design that holds up has five parts. It detects the platform with uname, it checks for the tools it needs and reports all the missing ones together, it downloads to a temporary file and verifies a checksum, it installs with install or mv so that a copy is atomic, and it prints exactly what it changed. Everything else is a detail of those five.

Shell
#!/bin/sh
set -eu

os=$(uname -s | tr '[:upper:]' '[:lower:]')
arch=$(uname -m)
case $arch in
  x86_64|amd64)  arch=amd64 ;;
  aarch64|arm64) arch=arm64 ;;
  *) echo "unsupported architecture: $arch" >&2; exit 1 ;;
esac
case $os in
  linux|darwin) ;;
  *) echo "unsupported system: $os" >&2; exit 1 ;;
esac

missing=""
for tool in tar gzip mktemp install; do
  command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"
done
if [ -n "$missing" ]; then
  echo "missing required tools:$missing" >&2
  exit 1
fi
echo "platform: $os/$arch"

Collecting every missing tool before exiting saves the user from fixing one, rerunning, and meeting the next. The architecture names are normalized with case, which is portable and readable, since uname -m reports x86_64 on Linux and arm64 on Apple silicon for the same family of machines. The tr call with POSIX character classes lowercases the system name without needing Bash's ${var,,}.

Portable file tests and the problems with find

The test command has a small portable core: -e, -f, -d, -r, -w, -x, -s, -L, -z and -n. Beware of -a and -o as logical operators and of -e on very old shells, where -f was the safe choice. Combine tests with separate brackets joined by && and ||, which have no ambiguity.

The utility find is portable in its basic form and diverges in its extensions. POSIX find has -name, -type, -mtime, -newer, -perm, -exec and -print. GNU adds -maxdepth, -printf, -print0 and -delete; BSD and macOS have -maxdepth and -print0 but not -printf; BusyBox has a reduced set depending on how it was built. For portable work, stay with -exec ... {} +, which is POSIX, and use -prune to avoid a directory in place of -maxdepth.

Shell
find . -name node_modules -prune -o -type f -name '*.js' -exec grep -l 'TODO' {} +
find /var/log -type f -mtime +30 -exec rm -f {} +

The first command skips the node_modules directory entirely and searches the rest, using only POSIX options. The second removes old log files and, unlike -delete, works wherever find does. When a task truly needs null-separated names, accept a dependency on GNU or BSD find and say so in the script header.

Text processing across implementations

The text tools are the other half of portability. Stick to POSIX options for sed, awk, grep, sort, cut, tr and head, and learn the commonly tripped differences. GNU sed lets you write \n in the replacement and use \+, \| and case-conversion escapes; BSD sed and BusyBox do not. POSIX head and tail take -n N, while the old -N form is gone in some implementations. sort -h, sort -V and sort -R are GNU extensions. grep -P, -o on BusyBox builds and --include are not portable. awk may be mawk, gawk, BWK awk or BusyBox awk, and the POSIX subset is the safe zone.

TaskNon-portablePortable form
Newline in a replacementsed 's/,/\n/g'tr ',' '\n' or an awk gsub
One or more in BREsed 's/a\+/X/'sed -E 's/a+/X/' or aa*
Version sortsort -VSplit fields with awk and sort numerically
Random lineshufSeed awk's rand()
Reverse a filetacsed '1!G;h;$!d'
In-place sedsed -iWrite a temporary file, then mv

The table is a starting list, not a complete one. When a script grows these workarounds, a clear comment next to each saves the next reader from "simplifying" it back into the GNU form and breaking macOS.

Locale and the portability of sorting

Many shell tools change behavior with the locale, and that is a portability problem even among identical systems. sort orders by the locale's collation rules, so in en_US.UTF-8 the lines a and B sort case-insensitively, while in the C locale all uppercase letters come first. Ranges in bracket expressions such as [a-z] can match uppercase letters in some locales. tr, cut and wc -m disagree about multibyte characters in the C locale. A script whose output must be stable sets the locale for the commands that depend on it.

Shell
LC_ALL=C sort file | LC_ALL=C uniq -c
export LC_ALL=C

Setting LC_ALL=C for the whole script gives byte-wise, predictable behavior and is also faster, at the cost of mishandling accented text in tools that need real character semantics. Use the per-command form when only some steps care. For user-facing text, leave the locale alone and let the user's setting apply. The point is to make the choice instead of inheriting whatever the caller had.

Handling signals portably

Signal handling is the last place where shells quietly differ. POSIX specifies the trap command and a short list of signals by name or number, and dash, ash and Bash all honor it. Differences appear in what happens to the exit status after a trap runs, in whether a trap runs when a foreground command is interrupted, and in the EXIT trap's behavior inside subshells and functions. A pattern that works in all of them traps the signals you care about, calls exit with the conventional code, and does the cleanup in the EXIT handler, so that cleanup has exactly one home.

Shell
#!/bin/sh
cleanup() { rm -rf "$tmp"; }
tmp=$(mktemp -d) || exit 1
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

Because the signal handlers only call exit, the EXIT trap runs next on every shell here. If a foreground child is running when the signal arrives, some shells wait for the child to finish before running the trap, so a long-running command can delay the cleanup; run long commands in the background and wait for them when prompt handling matters.

Documenting a portability contract

Portability is only useful if it is promised and kept. State the contract at the top of the file and in the README: the shells it supports, the platforms it was exercised on, the tools it requires and the ones it will use if present. Keep a short list of known deviations, such as "uses local, which POSIX does not define, but every supported shell implements it". A reader then knows which claims the script makes and which it deliberately does not.

Shell
#!/bin/sh
# Portable installer.
# Shells:    dash 0.5.12, BusyBox ash 1.35, Bash 5.2 in sh mode
# Platforms: Debian 12, Alpine 3.20, macOS 14
# Requires:  tar, gzip, mktemp, install
# Optional:  curl or wget (one is needed), sha256sum or shasum

A header like this is a checklist for the test matrix: every line is something a CI job can verify, using the setup from the testing guide. When the list changes, the tests change with it, and drift becomes visible.

Gradual migration of a large codebase

Teams that own hundreds of scripts rarely get to convert them in one go. A staged plan works better. First, inventory: list every script, its shebang, who runs it and where. Second, find the ones whose shebang says sh and that actually use Bash features, since those are the real bugs, and fix them or change the shebang to Bash and declare the dependency. Third, add ShellCheck and checkbashisms to CI in report-only mode so the count of problems is visible. Fourth, set a ratchet: the build fails if the number of findings goes up, and the team lowers the threshold as they fix. Fifth, for the scripts that matter most, such as container entrypoints and installers, add the dash and BusyBox test matrix.

Shell
git ls-files '*.sh' | while read -r f; do
  head -n1 "$f" | grep -q '^#!/bin/sh' && echo "$f"
done | xargs shellcheck -s sh -f gcc 2>&1 | grep -c 'SC30'

The pipeline counts findings in the SC30xx family, the codes ShellCheck uses for constructs undefined in POSIX sh, across only the scripts that claim to be sh. Tracking that single number over time shows whether the migration is moving. The approach respects a plain fact about legacy code: it keeps running, and the goal is to make each change safer than the last, not to stop the world.

Where this does not apply

Windows. Git Bash and WSL provide Bash, but PowerShell and cmd do not run any of this. A portable shell script is portable across Unix-like systems, not across all operating systems.

Interactive setup. Aliases, prompt configuration and completion belong in a file that only your own shell reads, such as ~/.bashrc, and need no portability at all.

Scripts that call platform-specific tools. A script that wraps apt, systemctl or launchctl is tied to a platform no matter what shell runs it. Portable syntax around non-portable commands is not portable.

Embedded devices with a restricted shell. Some routers ship BusyBox with applets compiled out. Check busybox --list on the actual device before trusting a general rule.

Where to go from here

If portability was the goal, the next steps are the tools that find bugs: testing with Bats and ShellCheck for automated checks, and debugging shell scripts for the moment a script behaves differently on a host you cannot easily reach. For the command-line tools themselves, the Linux command line guide lists what each of them does across systems.

Primary sources used here: the POSIX Shell Command Language, the POSIX sh utility page, the POSIX printf page, the Bash POSIX mode documentation, the dash manual source, the BusyBox documentation, the Debian Policy on scripts and the ShellCheck SC3010 page.

Also inBash
Taggedbashposix
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.