Your `#!/bin/sh` scripts run on some hosts where /bin/sh is dash and others where it is BusyBox ash. Both are called POSIX shells, yet a script using `[[ ]]` works on one and fails on the other. What differences remain between two POSIX shells, and how would you decide between fixing the script and pinning an interpreter?
answer
- POSIX is a floor, not a ceiling
- the permissive shell hides the bug
- compile-time options change what ash accepts
- echo and set -e are the classic corners
- decide by whether bash can be installed
basics
~20 sPOSIX is a floor, not a ceiling: each shell adds extensions, and BusyBox ash is commonly built with a bash-compatibility option that accepts [[ ]] while dash does not. Decide by audience — strict POSIX for entrypoints on minimal images, an explicit bash dependency for ordinary internal tooling.
solid answer
~50 sPOSIX specifies a minimum language and leaves plenty unspecified, so two conforming shells can differ in three ways: **extensions** (BusyBox ash is usually compiled with a bash-compat option, so `[[ ]]` and `${v/old/new}` quietly work there and fail under dash), **unspecified corners** (`echo` with backslashes or `-e`, `set -e` behaviour inside functions and command substitutions, `local` — which POSIX.1-2017 does not define at all), and **the utilities around the shell**, where BusyBox applets and GNU coreutils accept different options. The dangerous case is the permissive shell, because a bashism that works there is a latent bug for the strict one. The decision is about audience: if the script is a container entrypoint, an init script, or anything running before bash could be installed, write strict POSIX and gate it with shellcheck. Otherwise be honest — `#!/bin/bash`, install bash in the image, and stop paying a portability tax you never needed.
code
bash · 5 lines# Ask each candidate interpreter what it actually accepts
for shell in dash "busybox sh" bash; do
printf '%-12s ' "$shell"
$shell -c '[[ -n x ]] && echo "[[ ]] accepted"' 2>&1 | tail -n 1
donego deeper
Know that POSIX is a minimum every shell must meet, not a guarantee they behave the same, and that a script working on one sh does not mean it works on another.
Name the concrete divergences — compile-time extensions such as BusyBox's bash-compat, echo's backslash handling, local not being in POSIX.1-2017 — and explain why the permissive shell hides bugs.
Show the operational judgment: test against the strictest interpreter in the fleet, smoke-test on the real base image, and choose deliberately between rewriting in POSIX and declaring a bash dependency for each class of script.
Own the policy and its cost: which script classes must be POSIX, when shell stops being the right tool at all, and how the pipeline enforces the dialect so the decision does not have to be relitigated per pull request.
## POSIX is a floor The POSIX shell command language defines what every conforming shell must support. It does not forbid extensions, and it explicitly leaves behaviour unspecified in places. "POSIX shell" is therefore not one language with several implementations; it is the intersection of several languages, and each implementation is larger than the intersection in its own direction. ## The three kinds of divergence **Extensions.** dash is unusually austere — it was built to be small and fast for Debian's boot scripts, so it implements little beyond the standard. BusyBox's ash applet is configurable at compile time and is commonly built with its bash-compatibility option enabled, which brings in `[[ ]]`, `${var/pattern/replacement}`, `echo -e` and more. That is why the script in the question survives on the BusyBox host and dies on the Debian one. ksh93 goes much further still — associative arrays, floating-point arithmetic, compound variables. Every one of those is a construct that will pass your local test and fail on the strictest shell in your fleet. **Unspecified and divergent behaviour.** Even inside the standard there is room to differ: - `echo` is the classic. Whether it interprets `\n` and `\t`, and whether it honours `-e` or prints it, varies by shell and by XSI conformance. `printf '%s\n' "$x"` is the portable answer and is worth making a habit. - `set -e` (errexit) has famously subtle rules about functions, command substitutions and commands in condition context, and shells have historically differed in the corners. Two conforming shells can take different branches on the same script. - `local` is not in POSIX.1-2017 at all. dash, BusyBox ash and bash all provide it, with differing details on quoting and inheritance, so it is *practically* portable and *formally* an extension. - Options move over time: POSIX.1-2024 (Issue 8) added `set -o pipefail`, which POSIX.1-2017 did not have, so older shells on long-lived hosts will reject it. **The environment around the shell.** Half of real portability bugs are not shell dialect at all. BusyBox provides cut-down applets of `sed`, `date`, `readlink`, `grep` and `awk`; macOS ships BSD versions; GNU coreutils is a third dialect. `sed -i`, `readlink -f`, `grep -P` and `date -d` are all places where a perfectly POSIX script still fails. ## Why the permissive shell is the dangerous one If your development host runs the strict shell, non-portable code fails immediately and you fix it. If your development host runs the permissive one, the code ships and fails on someone else's machine, usually in the least convenient environment. The practical countermeasure is to test against the *strictest* interpreter in your fleet, not the most convenient one, and to run at least a smoke test on the actual base image. ## Making the decision This is a dependency decision, and the honest framing is: *what does this script cost if it needs bash?* **Write strict POSIX when bash may not exist or may not be reachable.** Container entrypoints and healthchecks on `alpine`, `busybox` or `distroless`-adjacent images; init and service scripts; anything in a package's maintainer scripts; anything running early in boot or in a rescue environment; anything shipped to hosts you do not control. Here `#!/bin/sh` is a real contract, and you enforce it with `shellcheck --shell=sh` in CI plus a run under `dash` and `busybox sh`. **Declare bash when you can simply install it.** For ordinary internal tooling on images you build yourself, `apk add bash` costs a couple of megabytes and buys arrays, `[[ ]]`, `local` with predictable semantics and far fewer quoting hazards. A wrong `#!/bin/sh` on a bashism-laden script is strictly worse than an honest `#!/bin/bash`, because it fails at an unpredictable time instead of at install time. **Consider leaving shell entirely.** Once a script needs arrays, associative lookups, JSON, or error handling with real structure, the portability argument has already been lost — it is a signal to move to Python or a compiled tool, not to work harder at shell. ## What good looks like One rule per class of script, written down. Entrypoints and system scripts: POSIX, linted, smoke-tested on the minimal image. Everything else: bash, pinned, installed in the image. Then the question "is this portable?" never has to be answered by guessing, because CI answers it on every push.
- Which everyday builtin is the worst offender for portable output, and what do you use instead?`echo`. Whether it expands `\n` and `\t`, and whether it treats `-e` as an option or prints it literally, differs between shells and between XSI-conforming and non-conforming implementations. Use `printf '%s\n' "$value"` — the format string is specified, it never eats a leading `-`, and it behaves the same everywhere.
- If bash is only a couple of megabytes, why not install it everywhere and stop worrying?Often you should. The cases where you cannot are real but narrow: images you do not build, distroless-style bases, early-boot and rescue paths, and packaging scripts that run before dependencies are resolved. Outside those, an explicit bash dependency is more honest than a #!/bin/sh script that only works by luck.
- How would you stop this class of failure recurring across a repository of scripts?Make the dialect declared and enforced: shellcheck over every script in CI with the shebang deciding the ruleset, a non-zero exit failing the build, and one smoke test per script on the minimal image it actually runs on. That turns a portability question that used to be answered by opinion into a build result.
saying these in an interview costs you the question
- Assumes any two POSIX shells behave identically
- Tests only on the permissive shell and calls it portable
- Says echo is safe because it works on their machine
- Treats local and pipefail as guaranteed POSIX features
- Keeps #!/bin/sh on a bashism-heavy script to look portable