skip to content

A team standard says every shell script in the repo must be POSIX-compliant `#!/bin/sh` "for portability". How would you decide whether that policy is right, and what does writing POSIX sh actually cost you?

level: principalimportance: nice to knowfreq 28%

answer

  1. start from the actual runtime list
  2. portability is a cost, not a virtue
  3. losing arrays makes code less safe
  4. boundary scripts versus inside-the-fence scripts
  5. sometimes the answer is not shell

basics

~20 s

Decide from the list of environments the script must actually run in. POSIX sh buys you machines where bash is absent, but costs arrays and [[ ]], which often makes the code less safe rather than more. Guaranteeing bash is usually cheaper.

solid answer

~60 s

I'd start by asking what the policy is buying, concretely: which machines run these scripts, and on how many of them is bash genuinely absent? For a fleet of Linux hosts and CI images that all have bash, POSIX sh is a cost with no benefit. And it is a real cost — POSIX sh has no arrays, so a list of arguments containing spaces has no safe representation beyond the positional parameters; no `[[ ]]`, so every `[` test needs rigorous quoting to avoid word splitting and globbing; no `local` in the specification; no process substitution; and `set -o pipefail` is not something every `/bin/sh` provides. The usual result is *less* safe code written by people who miss a quote. So my policy is the inverse: default to bash, pinned with a version guard, and guarantee bash exists in every image; reserve POSIX sh for the genuine boundary cases — installer bootstraps, minimal container entrypoints, anything that must run before bash is installed. And if a script needs associative arrays and structured error handling, that is a signal it has outgrown shell entirely.

code

bash · 6 lines
bash
#!/bin/sh
set -- --verbose --output "/tmp/report with space.txt"
if [ -n "${DEBUG-}" ]; then
  set -- "$@" --debug
fi
printf 'arg: %s\n' "$@"

go deeper

for a junior

Understand that POSIX sh is a smaller language than bash and that choosing it means giving up arrays and [[ ]]. Ask which shell a script is expected to run under before you start writing it.

for a middle

Be able to list the concrete constructs you lose and show the standard workaround for each — set -- for argument lists, careful quoting inside [ ], tr instead of case-modifying expansions.

for a senior

Argue the tradeoff from the deployment reality: which images and hosts actually run the script, whether adding bash to an image is cheaper than making the code bilingual, and how you would verify the boundary scripts against their real target shell.

for a principal

Own the standard for the whole codebase: one default dialect, an explicit short list of boundary scripts that must be POSIX, bash guaranteed in every image, and an articulated point at which a task stops being a shell script at all.

## What the policy is really trying to buy "POSIX for portability" is a proxy for a concrete question: *on which machines must this script run, and what is guaranteed to be installed there?* Answer that first, because the policy is either obviously right or obviously wrong once you have the list. Common answers: - Every host is a company-managed Linux box with bash installed → POSIX buys nothing. - Scripts run inside minimal container images with no bash → POSIX is mandatory for those scripts. - Scripts are distributed to unknown machines (an installer people pipe from a URL, a bootstrap that provisions a box) → POSIX is mandatory, because you cannot assume anything. - Scripts run on developer laptops including Macs → the constraint is not POSIX, it is the *bash version*, which is a different problem with a different fix. A blanket policy fails because these have different answers, and applying the strictest one everywhere taxes the 95% of scripts that never leave a controlled environment. ## What POSIX sh actually costs The constructs you give up are not conveniences; several of them are safety features. **No arrays.** This is the big one. In bash you can build a command safely: ```bash args=(--verbose --output "/tmp/report with space.txt") mytool "${args[@]}" ``` POSIX sh has exactly one list type: the positional parameters. The idiom is real and works, but it is one list per scope and it consumes `$@`: ```sh set -- --verbose --output "/tmp/report with space.txt" mytool "$@" ``` When a script needs two lists, authors reach for a space-separated string and unquoted expansion, and now a filename with a space is a bug — the exact class of defect the policy claimed to prevent. **No `[[ ]]`.** Inside `[[ ]]` the left-hand side is not word-split or glob-expanded, so `[[ $x = "" ]]` is safe even when `$x` is unset or contains spaces. With `[`, an unquoted variable becomes multiple arguments and the test errors or, worse, silently succeeds. Portable code has to be flawlessly quoted; bash code merely has to be well quoted. **No `local` in the specification.** Most `/bin/sh` implementations provide it as an extension, but it is not standard, so a strict reading forbids it — and shell functions without local variables leak state into the global namespace. **No process substitution `<( )`**, so you go back to temp files and the cleanup they require. **No `${var^^}`**, so you shell out to `tr`. **No `+=`**. And `set -o pipefail` is not available in every `/bin/sh`, which silently costs you the failure detection it provides. ## Where POSIX sh genuinely wins - **Minimal container images.** An entrypoint or healthcheck script in an image with no bash package must be `#!/bin/sh`, full stop. - **Bootstrap and installer scripts.** Anything a stranger runs on a machine you have never seen, before your dependencies exist. - **Very early boot / recovery contexts**, where the filesystem holding a larger shell may not be mounted. - **Scripts shipped inside another product**, where you do not control the runtime at all. Notice the shape: these are the scripts that run *before or outside* your environment. Everything else runs *inside* it, where you get to decide what is installed. ## The policy I would actually write 1. Default dialect is bash, invoked through `#!/usr/bin/env bash`, with a documented minimum version and a guard that enforces it. 2. Every image that runs our scripts installs bash. This is a one-line change in a Dockerfile and it retires the entire question for the scripts inside. 3. A short, explicit list of boundary scripts — entrypoints for minimal images, the installer, the bootstrap — is marked POSIX-only, and those get exercised against the actual target shell in CI rather than assumed correct. 4. Portability of *commands* is tracked separately from portability of *shell syntax*; a pure POSIX script that calls `sed -i` or `grep -P` is not portable either, and people routinely conflate the two. ## The exit ramp The most valuable judgment here is knowing when the dialect question is moot. If a script wants associative arrays, structured error handling, JSON parsing and retries, the answer is not "write it in POSIX sh" or even "write it in bash 5" — it is that the task outgrew shell. Shell is excellent glue for sequencing commands and wiring streams; it is a poor language for data structures. A team policy that never allows that conclusion produces 800-line scripts nobody can safely change.

  • Someone argues POSIX sh is safer because it is a smaller language. What do you say?
    That it is smaller but not safer. The features it removes — arrays and `[[ ]]` — are precisely the ones that make quoting mistakes non-fatal. Without arrays, authors encode lists as space-separated strings and expand them unquoted, which reintroduces the word-splitting bugs the policy was supposed to prevent. Smaller language, larger blast radius per mistake.
  • If a script must be POSIX sh, how do you build up a variable-length argument list safely?
    Use the positional parameters as the one available list type: `set -- base args`, then `set -- "$@" --extra` to append, and finally `mytool "$@"`. Each element keeps its spaces and newlines intact. The limitation is that there is one such list per shell or function scope, which is exactly why multi-list logic pushes people toward unsafe string concatenation.
  • How do you decide a script has outgrown shell altogether?
    The signals are structural: you need real data structures rather than lists of strings, you are parsing JSON or XML, you need error handling with context rather than exit codes, you are retrying with state, or the file has passed a few hundred lines with nested functions. Shell is glue for sequencing commands; once the logic is about data rather than processes, a program in a real language is cheaper to own.

saying these in an interview costs you the question

  • Treating portability as free rather than a tradeoff
  • Assuming POSIX sh code is automatically portable
  • Writing POSIX everywhere when bash is guaranteed
  • Encoding argument lists as space-separated strings
  • Never concluding a script should stop being a script

context