Skip to content
shellmap

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

    dash

    Supported 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)

    dash

    Treats `-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

    dash

    Only the POSIX form `name() { ... }` works. `function name { ... }` is a syntax error.

    Bash

    Both `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

    dash

    Not 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 <<<

    dash

    Not 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}

    dash

    Only POSIX parameter expansions: `${var:-default}`, `${var:=default}`, `${var:?error}`, `${var:+alt}`, `${var#prefix}`, `${var##prefix}`, `${var%suffix}`, `${var%%suffix}`. NO case conversion, NO substitution.

    Bash

    Adds `${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

    dash

    No arrays. POSIX has only scalar variables and positional parameters `$1 $2 ...`. dash maintains this strict POSIX behavior.

    Bash

    Indexed 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

    dash

    No `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

    dash

    POSIX arithmetic `$((1+2))` works fine; `expr 1 + 2` is the older form. `((...))` (no `$`) as a STATEMENT is NOT in POSIX — dash rejects it.

    Bash

    Both `$((...))` (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

Other comparisons