dash vs Bash
Why your `#!/bin/sh` script breaks on Debian and Ubuntu. dash is the strict POSIX shell that lives at `/bin/sh` on most Linux distros — fast, small, and unforgiving toward bash extensions.
Summary
dash (the Debian Almquist Shell) is a small, fast, POSIX-strict shell. Debian made `/bin/sh` a symlink to dash in 2009 (Ubuntu 2006). The motivation was boot speed — early-boot init scripts execute thousands of small shell calls, and dash is several times faster than bash for that workload.
Bash, when invoked as `bash`, ships dozens of extensions on top of POSIX. When invoked as `sh` (or via `bash --posix`), bash disables SOME of those extensions but still permits many. dash disables ALL of them — there is no "almost POSIX" mode, just POSIX.
The classic developer trap: write a script with `#!/bin/sh`, test it on macOS (where `/bin/sh` is bash), deploy to a Debian/Ubuntu container, watch it fail. Almost every reported "works on my machine" bash-vs-shell-script bug traces back to this difference. The fix is either `#!/usr/bin/env bash` (depend on bash) or eliminate the bash-only constructs.
Syntax & semantic differences
local in functions
dashSupported as a non-POSIX extension. `local var=value` works in dash — but POSIX-strict shells do not require it. **Caveat:** dash's `local` does NOT inherit the parent's value the way bash does — `local var` initializes to empty, not to the existing scope's value.
Bash`local var=value` works; `var` inherits the surrounding scope's value if assigned without a value (`local var; var=...`). Bash also has `declare`/`typeset` with attribute flags (`-i` integer, `-r` readonly, `-A` associative array) — dash has none of these.
Both shells have `local` but with different scoping semantics. Truly portable code uses a subshell instead: `( var=value; ... )` — assignments inside the parens cannot leak out.
echo -e (escape interpretation)
dashTreats `-e` as a literal argument to echo: `echo -e "a\nb"` prints `-e a\nb` (the `-e` and the literal backslash-n). dash's echo always interprets escapes — there is no flag.
Bash`echo -e "a\nb"` interprets `\n` as a newline. `echo` without `-e` does NOT (in default mode). `shopt -s xpg_echo` makes echo always interpret escapes.
The portable answer is `printf` — `printf "a\nb\n"` works identically in dash, bash, busybox ash, and every POSIX shell. Replacing `echo -e` with `printf` is the single highest-value portability change a script can make.
function keyword
dashOnly the POSIX form `name() { ... }` works. `function name { ... }` is a syntax error.
BashBoth `name() { ... }` and `function name { ... }` (and `function name() { ... }`) work. The `function` keyword form has subtle DEBUG-trap differences but is otherwise equivalent.
Always use `name() { ... }` — works in dash, bash, ksh, busybox ash, zsh in sh-mode. The `function` keyword has zero advantage and breaks portability.
[[ ]] conditional
dashNot supported — `[[` is parsed as a literal command name. Use single `[ ]` (a synonym for `test`), with the strict quoting and operator rules POSIX requires.
Bash`[[ ]]` is the bash-extended test: no word-splitting on the inside, supports `&&`/`||`, `==` with glob patterns, `=~` for regex.
The most common bash-only construct in shell scripts. To port, rewrite `[[ "$a" == "$b" ]]` as `[ "$a" = "$b" ]` (single `=`, careful quoting). For regex, escape to `grep` or `awk`.
Here-strings <<<
dashNot supported. `grep foo <<< "$var"` is a syntax error.
Bash`<<<` feeds a string into stdin: `grep foo <<< "$var"` is equivalent to `echo "$var" | grep foo` (with a subtle difference: `<<<` does not fork).
Portable replacement: `printf "%s" "$var" | grep foo` — works in dash and bash. Slightly slower (forks a subshell), but POSIX-compliant.
read -p (prompt) and read -r (raw)
dash`read -r var` works (`-r` is POSIX-2018). `read -p "prompt: " var` does NOT — `-p` is bash-specific.
Bash`read -p "prompt: " var` works. Also `-t TIMEOUT`, `-n NCHARS`, `-s` (silent), `-e` (readline) — all bash-only.
Portable: `printf "prompt: "; read -r var`. The two-line form costs nothing and works everywhere.
String operations ${var,,} / ${var^^} / ${var//pat/rep}
dashOnly POSIX parameter expansions: `${var:-default}`, `${var:=default}`, `${var:?error}`, `${var:+alt}`, `${var#prefix}`, `${var##prefix}`, `${var%suffix}`, `${var%%suffix}`. NO case conversion, NO substitution.
BashAdds `${var,,}` (lowercase), `${var^^}` (uppercase), `${var,}` (first char lower), `${var^}` (first char upper), `${var/pat/rep}` (replace first), `${var//pat/rep}` (replace all), `${var:offset:length}` (substring — also in dash but with quirks).
For case conversion in dash, shell out to `tr`: `lower=$(echo "$var" | tr A-Z a-z)`. For substitution: `sed`. Slower than the bash builtins but unavoidably portable.
Arrays
dashNo arrays. POSIX has only scalar variables and positional parameters `$1 $2 ...`. dash maintains this strict POSIX behavior.
BashIndexed arrays `arr=(a b c)`, associative arrays via `declare -A` (bash 4+), array slicing `${arr[@]:1:2}`, append `arr+=(d)`, all the usual fanfare.
Porting array-heavy bash to dash usually means rewriting the data model — push the work to `awk` or `python`, or join with a separator into a string and split with `IFS` when needed.
pipefail and inherit_errexit
dashNo `set -o pipefail` (pipelines return the LAST command's exit code; failures in earlier stages are silently swallowed). No `inherit_errexit` either — `set -e` does NOT propagate into command substitutions.
Bash`set -o pipefail` makes the pipeline exit with the rightmost non-zero status. `shopt -s inherit_errexit` (bash 4.4+) makes `set -e` inheritable into `$(cmd)`. Both are critical for "strict mode" scripts.
The biggest hidden-bug risk when porting bash strict-mode scripts to dash. Scripts that rely on `set -euo pipefail` for safety silently lose pipeline-failure detection on dash. Add explicit `cmd1 > /tmp/out || exit; cmd2 < /tmp/out` checks, or accept the limitation.
Arithmetic, $((..)) — both support it
dashPOSIX arithmetic `$((1+2))` works fine; `expr 1 + 2` is the older form. `((...))` (no `$`) as a STATEMENT is NOT in POSIX — dash rejects it.
BashBoth `$((...))` (expansion) and `((...))` (statement) work. The statement form returns exit-code 0/1 based on the result, useful in `if`/`while`.
Use `$((...))` for arithmetic expansion (works in both). Use `[ $((expr)) -ne 0 ]` instead of `((...))` for portable conditionals.
Gotchas when porting between them
- **The single most-encountered dash trap: `source` doesn't exist** — POSIX uses `.` (dot). `source ./config.sh` is bash; `. ./config.sh` is portable. Symptom on dash: `source: not found`. Easy fix, but trips every newcomer.
- **`local` exists but with reset-to-empty semantics**: bash's `local var` (without assignment) preserves the surrounding scope's value. dash's `local var` RESETS the variable to empty. Code that depends on `local x; modify_global_x; use $x` works in bash, breaks in dash.
- **`[ ]` test quirks**: POSIX `[ -n "$var" ]` (NON-empty) and `[ -z "$var" ]` (empty). dash strictly enforces — `[ -n $var ]` (unquoted) silently becomes `[ -n ]` when var is empty, which is "true" because `-n` is treated as a non-empty string. The bug only fires on the empty case. Always quote.
- **`time` is a reserved word in bash, a binary in dash**: `time { cmd1; cmd2; }` (timing a compound command) works in bash but not dash. dash supports only `time SIMPLE_COMMAND` (no compound commands, no shell builtins). Workaround: wrap in a subshell — `time sh -c "cmd1; cmd2"`.
- **busybox ash diverges from dash slightly**: Alpine Linux uses busybox's ash, which is a different POSIX implementation. Most things match dash but busybox ash supports a few extra extensions and has some bugs dash doesn't. The portable test is "does it work in BOTH dash AND busybox ash" — `shellcheck --shell=sh` is the only reliable lint that catches both.
Full references
- dashDebian/Ubuntu `/bin/sh` since 2009. Tiny, fast, POSIX-strict — explains "works in bash, fails in /bin/sh" surprises.
- Bash referenceThe default Linux shell. Default on macOS through 10.14, still ubiquitous in scripts.