skip to content

POSIX sh, dash and ksh

The plain POSIX shells your scripts land on when /bin/sh is not bash: dash on Debian and Ubuntu, BusyBox ash in Alpine, ksh on older Unix. Interviewers ask because a script that works locally and breaks in the container is almost always this.

part ofCommand-line shellsoverview, primer and where to startread it →
on this pageshow

questions

5

A deploy script whose first line is `#!/bin/sh` runs fine on a RHEL host but on Debian fails with `[[: not found` and `Syntax error: "(" unexpected`. What is /bin/sh on each system, and why do those lines fail on one and not the other?

level: middleimportance: must knowfreq 72%

answer

  1. same shebang, two different programs
  2. check what /bin/sh points to
  3. Debian and Ubuntu link sh to dash
  4. bash as sh keeps its keywords
  5. [[ ]] and arrays are bash-only

basics

~20 s

On RHEL /bin/sh is a symlink to bash, which still accepts [[ ]] and arrays even when invoked as sh. On Debian and Ubuntu /bin/sh is dash, a small strict POSIX shell that has neither. The script was never portable; RHEL hid the bug.

solid answer

~50 s

`#!/bin/sh` does not name a language, it names whatever program that path points to. On RHEL and Fedora `/bin/sh` is a symlink to bash: bash notices it was invoked as `sh` and turns on POSIX mode, but POSIX mode only changes startup files and a handful of behaviours — it does **not** remove bash keywords, so `[[ ]]` and `arr=(a b c)` still work. On Debian and Ubuntu `/bin/sh` is dash, a deliberately small Almquist-shell descendant that implements little more than the POSIX shell command language. `[[` is not a keyword there, so dash looks for a command called `[[` and reports `[[: not found`; `arr=(...)` is not valid POSIX assignment syntax, so it dies at parse time on the parenthesis. The fix is a decision, not a patch: either rewrite those lines in POSIX (`[ -n "$x" ]`, a loop or positional parameters instead of an array) or change the shebang to `#!/bin/bash` and accept bash as a real dependency.

code

bash · 16 lines
bash
#!/bin/sh
# These two lines are bash-only and break under dash:
#   if [[ -n "$1" ]]; then echo yes; fi
#   hosts=(web1 web2)

# POSIX equivalents that run under dash, ash, ksh and bash:
if [ -n "$1" ]; then
  echo "got $1"
fi

for host in web1 web2; do
  echo "$host"
done

set -- web1 web2
echo "first host: $1, count: $#"

go deeper

for a junior

Know that /bin/sh is not guaranteed to be bash, and that Debian and Ubuntu point it at dash. Be able to run ls -l /bin/sh to find out which shell you are actually getting.

for a middle

Explain the mechanics: bash invoked as sh enters POSIX mode but keeps its keywords, dash implements little beyond POSIX, and name the POSIX replacements for [[ ]], arrays, source and function.

for a senior

Show the judgment call — rewrite in POSIX versus declaring #!/bin/bash and installing bash — and say which you would pick for a container entrypoint versus an internal helper, plus how you would verify it before it ships.

for a principal

Own the policy: decide the organisation's portability target, whether shell is even the right tool for scripts crossing that many platforms, and how CI enforces the choice so the failure cannot reach production images again.

## The shebang names a program, not a language The kernel reads the first line of an executable script, and if it begins with `#!` it runs the named interpreter with the script as an argument. `#!/bin/sh` therefore means "run this with whatever program lives at /bin/sh on this machine". That program is different on almost every family of system: - **Debian, Ubuntu and derivatives** — `/bin/sh` is `dash`, the Debian Almquist Shell. Debian switched in Debian 6 and Ubuntu in 6.10, mainly because dash starts faster and is smaller, which mattered for the hundreds of `#!/bin/sh` scripts run during boot. - **RHEL, CentOS, Fedora, SUSE** — `/bin/sh` is a symlink to `bash`. - **Alpine and other BusyBox systems** — `/bin/sh` is a symlink into the BusyBox multi-call binary, which provides an `ash` applet. - **Historic Unix** — Solaris 10's `/bin/sh` was the pre-POSIX Bourne shell, with the POSIX shell tucked away at `/usr/xpg4/bin/sh`; Solaris 11 made `/bin/sh` ksh93. You can always ask the machine: ```sh ls -l /bin/sh # lrwxrwxrwx ... /bin/sh -> dash readlink -f /bin/sh # /usr/bin/dash ``` ## Why bash-as-sh is not a POSIX shell When bash is invoked under the name `sh` it enters what its manual calls *sh mode*: it changes which startup files it reads (`$ENV` instead of `~/.bashrc`) and turns on POSIX-conforming behaviour for a list of specific details. What it does **not** do is amputate its own grammar. `[[ ... ]]` is still a shell keyword, `arr=(a b c)` is still a valid array assignment, `local`, `source`, `${var/old/new}` and `<(...)` all still work. `set -o posix` and `bash --posix` behave the same way — they are conformance switches, not a portability harness. That is the whole trap. Every RHEL developer who types `#!/bin/sh` out of habit is really writing bash and getting away with it, because the interpreter that runs it is bash. The bill arrives the first time the script lands on a Debian container, a Yocto image, or an Alpine CI runner. ## The two failing lines, specifically `[[ -n "$x" ]]` — in bash and ksh, `[[` is a *keyword* parsed specially by the shell, which is why it can do pattern matching and unquoted variable tests. dash has no such keyword, so `[[` is just a word in command position; dash searches `PATH` for an executable named `[[`, finds none, and prints `[[: not found` with exit status 127. Note this is a **runtime** failure, so it only happens if that branch executes. The portable equivalent is the `test` builtin, spelled `[`: `[ -n "$x" ]`, with the variable quoted because POSIX `test` really does need protection from word splitting. `hosts=(web1 web2)` — POSIX defines assignment as `name=word`. A parenthesis there is a syntax error, and it is a **parse** error, so the script dies before running a single line even if the array is never used. POSIX shells have exactly one array-shaped thing: the positional parameters (`set -- web1 web2`, then `"$@"`). For a fixed list a plain `for host in web1 web2; do ...; done` is usually all you needed. Other members of the same family: `source file` (POSIX spells it `. file`), `function f { }` (POSIX is `f() { }`), `echo -e` (dash's echo does not accept `-e` and prints it literally — use `printf`), `${var/old/new}`, `${var^^}`, `<(cmd)`, and `local` (not in POSIX at all, though dash and BusyBox ash both provide it). ## Choosing a fix There are only two honest options, and picking one is the point of the question: 1. **Mean `#!/bin/sh`.** Rewrite the bashisms in POSIX and verify with a linter and a run under the real target shell. Right for container entrypoints, init and packaging scripts, and anything that must run on a minimal image before bash exists. 2. **Say `#!/bin/bash`.** Declare the dependency and make sure bash is installed (on Alpine, `apk add bash`). Right for ordinary internal tooling, where bash's arrays and `[[ ]]` are worth a few megabytes. What is not an option is leaving `#!/bin/sh` on a script full of bashisms because it happens to work on your laptop. That combination is a latent failure that surfaces on someone else's machine, usually in CI, usually at 2am.

  • If bash is running as sh and POSIX mode does not remove [[ ]] or arrays, what does it actually change?
    Mostly startup and conformance details: invoked as `sh` it reads `$ENV` rather than `~/.bashrc`, and POSIX mode adjusts behaviours such as how `kill`, `time` and word expansions are handled, and makes certain errors in special builtins fatal. None of it disables bash keywords, so it is useless as a portability check.
  • Why did the arrays line kill the script even on a branch that never ran, while [[ ]] only failed when reached?
    They fail at different stages. `arr=(a b c)` is invalid POSIX grammar, so dash rejects it while parsing the enclosing compound command — nothing executes. `[[` parses fine as an ordinary command word, so it only fails at execution time, with a 127 `not found`, when that branch is actually taken.
  • How would you find out quickly whether a large script is really POSIX before you promise that it is?
    Run `shellcheck` on it with `#!/bin/sh` as the shebang so it applies POSIX rules, and run it under the real target interpreter — `dash script.sh` or `busybox sh script.sh` — in a throwaway container. Reading it yourself is unreliable; almost nobody spots every bashism by eye.

#!/bin/sh is like specifying "a car" on a rental booking: on one lot you get the estate you always drive, on another a two-seater. Both are cars; only one fits the luggage you packed.

saying these in an interview costs you the question

  • Says /bin/sh is just another name for bash everywhere
  • Thinks the error means bash is not installed on Debian
  • Claims bash --posix or set -o posix makes bash behave like dash
  • Assumes POSIX sh has arrays because every modern shell does
  • Concludes the script is portable because bash script.sh works

context

open as a page

What does the `#!/bin/sh` line at the top of a script actually cause to happen when the file is executed, and how does choosing `#!/bin/sh`, `#!/bin/bash` or `#!/usr/bin/env bash` change which interpreter ends up running it?

level: juniorimportance: should knowfreq 60%

basics

~20 s

The kernel reads the first line, and if it starts with #! it runs the named interpreter with the script as an argument. /bin/sh means whatever POSIX-ish shell that path holds, /bin/bash demands bash at that exact path, and /usr/bin/env bash finds the first bash on PATH.

open as a page

Before shipping a script that begins with `#!/bin/sh` to hosts where /bin/sh is dash, how would you check it for bash-only constructs, and what does `dash -n script.sh` fail to catch?

level: middleimportance: should knowfreq 36%

basics

~20 s

Lint it with shellcheck under sh rules, run Debian's checkbashisms over it, and actually execute it under the real interpreter in a throwaway container. A -n parse check only catches syntax errors, so runtime bashisms such as [[ ]], local and echo -e sail straight through.

open as a page

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?

level: seniorimportance: should knowfreq 32%

basics

~20 s

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

open as a page

Where does the POSIX shell command language actually come from, and how do ksh88/ksh93 and the C shell family (csh and tcsh) relate to it?

level: middleimportance: nice to knowfreq 18%

basics

~20 s

POSIX standardised the Bourne shell as extended by David Korn's ksh88, which is why bash, ksh, dash and ash all share one grammar. The C shell is a separate lineage with incompatible syntax; it contributed interactive features such as job control and history, not the scripting language.

open as a page