Skip to content
shellmap

dash vs sh (POSIX)

A bait-and-switch comparison: `dash` IS an implementation of `sh`. The interesting question is "which sh am I actually running?" — Debian/Ubuntu = dash, Alpine = busybox ash, macOS / RHEL / Fedora = bash --posix, AIX / OpenBSD = ksh. Each is POSIX-conformant on paper; each has its own non-POSIX extras and refusals.

Summary

`sh` is the POSIX 2017 specification — an abstract contract that says how a shell language MUST behave. `/bin/sh` on your system is a binary that *claims* to implement that contract. The five common bindings: `dash` (Debian, Ubuntu, Mint), `busybox ash` (Alpine and most container base images that aren't Debian-derived), `bash --posix` (macOS, RHEL/Fedora/CentOS Stream, Oracle Linux), `ksh` (AIX, OpenBSD), `mksh` (some hardened distros). Each is "POSIX-conformant" — and each adds its own non-POSIX extras, or refuses things the others accept.

Dash specifically is the Debian Almquist Shell, a 1997 fork of NetBSD's `ash` rewritten for size and speed. Debian made `/bin/sh` a symlink to dash in 2009 (Ubuntu in 2006) to speed up early-boot init scripts, where thousands of small sh invocations add up. Dash refuses bash extensions strictly, but it DOES implement a handful of POSIX-debated extras: `local` (also in mksh / ksh93 / busybox ash, NOT in POSIX), `kill -%jobspec`, and a few read/echo flags.

The practical upshot: writing `#!/bin/sh` and testing only on Debian gives you false confidence — your script may use a dash-specific extra that fails on busybox ash, or behave one way under `bash --posix` and differently under dash. `shellcheck --shell=sh` is the only reliable lint that catches POSIX-only-rule violations across implementations; `dash -n script.sh` only catches dash-rejected syntax.

Syntax & semantic differences

  • What `/bin/sh` actually points to

    dash

    **dash** when you're on Debian (since 2009), Ubuntu (since 6.10, 2006), Linux Mint, Pop!_OS, Raspbian, or any Debian-derived container image (`debian:slim`, `ubuntu:latest`, etc.). Detect with `readlink /bin/sh` → `/bin/dash` or `/usr/bin/dash`.

    sh (POSIX)

    Depends entirely on your distro. **busybox ash** on Alpine + most minimal containers (`alpine:latest`, BusyBox-based embedded). **bash --posix** on macOS (all versions), RHEL / CentOS Stream / Fedora / Oracle Linux / Amazon Linux 2. **ksh** on AIX, OpenBSD. **mksh** on hardened-Debian variants. Detect with `ls -l /bin/sh`.

    The single most useful one-liner: `readlink /bin/sh 2>/dev/null || ls -l /bin/sh`. If you don't know which sh you're testing against, you don't know whether "it works" means "it's POSIX" or "it works on this binary".

  • `local` keyword for function-scoped variables

    dash

    Available as a dash extension (since dash 0.5.4, 2007). `f() { local x=1; …; }` works.

    sh (POSIX)

    NOT in POSIX 2017. Available in: dash, busybox ash, ksh93 (as `typeset` syntactically), mksh. NOT available in: bash --posix (silently uses `local` from bash — confusingly POSIX-correct-feeling but not POSIX-strict). For truly portable code, use the subshell trick: `f() { ( x=1; … ); }`.

    `shellcheck --shell=sh` flags `local` as non-POSIX even though dash + busybox ash + macOS's bash all happily run it. If your build pipeline tests only on Debian (dash), `local` survives — until somebody runs it on a true-POSIX shell or via `shellcheck`.

  • `echo -e` interpretation of `\n`, `\t`

    dash

    dash: `echo -e "a\nb"` prints the literal `-e a\nb` — dash does NOT interpret `-e` as a flag, it's POSIX-correct.

    sh (POSIX)

    Inconsistent across sh implementations. **bash --posix**: still interprets `-e` (bash defaults bake it in). **busybox ash**: interprets `-e`. **dash**: literal. The POSIX spec says `echo` is implementation-defined for any arguments beginning with `-` or containing backslashes. Result: `echo` is unportable for anything beyond a plain literal string.

    Use `printf "%s\n" "$var"` always. `printf "a\nb\n"` produces the same output everywhere. `echo` portability is so bad that POSIX itself recommends printf for any non-trivial output.

  • `source` vs `.` for sourcing scripts

    dash

    dash: `.` works (POSIX). `source` does NOT — dash explicitly rejects it as "source: not found".

    sh (POSIX)

    bash --posix: BOTH `.` and `source` work (bash baked-in alias survives POSIX mode). busybox ash: `.` works, `source` is an alias (since busybox 1.21, 2013). ksh93: both. mksh: both.

    Writing `source file.sh` and testing only on macOS (bash --posix) or Alpine (busybox ash) gives false-positive — `dash` will fail with "source: not found". Always use `. file.sh` for cross-sh portability.

  • `[[ ]]` double-bracket conditional

    dash

    dash: REJECTED. `[[ -f file ]]` errors with `[[: not found` — dash sees `[[` as a command name and tries to look it up.

    sh (POSIX)

    bash --posix: ACCEPTED. busybox ash: REJECTED. ksh93: ACCEPTED (it predates bash's borrowing). mksh: ACCEPTED.

    The `[[` test is the canonical "works on macOS, fails in CI" tripwire — bash --posix happily accepts it, Debian/Alpine reject it. Always use `[ ]` single-bracket for cross-sh portability. `[ -f "$f" ]` is the safe form.

  • `<<<` here-string

    dash

    dash: REJECTED. Syntax error on first read.

    sh (POSIX)

    bash --posix: ACCEPTED (bash extension survives POSIX mode). busybox ash: REJECTED. ksh93 (newer than ksh88): ACCEPTED. mksh: ACCEPTED.

    Portable replacement: `printf '%s\n' "$var" | grep pattern` instead of `grep pattern <<< "$var"`. The pipe-from-printf form runs in every shell.

  • `read -p prompt` (prompt flag)

    dash

    dash: `-p` REJECTED. `read` accepts `-r` (POSIX) only. Use `printf "prompt: "; read -r var`.

    sh (POSIX)

    bash --posix: `-p` works. busybox ash: `-p` works (since 1.20). ksh93: `-p` does something else entirely (read from co-process). mksh: `-p` for co-process.

    `read -p` is a fault line — works in 3 of 5 common sh, errors in 2. Always split the prompt and the read for portability.

  • `${var,,}` / `${var^^}` case conversion

    dash

    dash: REJECTED. Syntax error.

    sh (POSIX)

    bash --posix: ACCEPTED (bash extension). busybox ash: REJECTED. ksh93: NOT this syntax — uses `typeset -l var` (lowercase) / `typeset -u var` (uppercase) instead. mksh: like ksh93.

    Portable substitute: `echo "$var" | tr "[:upper:]" "[:lower:]"`. Pure-shell case conversion is non-POSIX; always pipe to `tr`.

  • Pipefail (catching pipeline failures)

    dash

    dash: NO `set -o pipefail`. `set -e` catches a failed standalone command, but `false | true` returns 0 — silent pipeline failures.

    sh (POSIX)

    bash --posix: `set -o pipefail` works. busybox ash: NOT supported (silent). ksh93: works (`set -o pipefail`). mksh: works.

    The most consequential portability gap for scripts using `set -e`. If your script is `#!/bin/sh; set -e; cmd1 | cmd2`, a failure in cmd1 is INVISIBLE on Debian (dash) and Alpine (busybox ash). Either accept the gap, or move to `#!/usr/bin/env bash; set -euo pipefail`.

  • Arrays

    dash

    dash: NO arrays. Positional parameters `$1 $2 ... $@` are the only sequence type — same as POSIX.

    sh (POSIX)

    bash --posix: arrays still work (bash extension). busybox ash: NO arrays. ksh93: arrays (with its own 1-indexed twist). mksh: arrays.

    Writing `arr=(a b c)` and testing on macOS's `/bin/sh` (bash --posix) passes; the same script crashes on Debian (dash) and Alpine (busybox ash). For portable code, use space-separated strings + `IFS` parsing, or push the data structure to an external program (awk / python).

  • Process-substitution `<(cmd)`

    dash

    dash: REJECTED. Use temp files: `cmd1 > /tmp/a; cmd2 > /tmp/b; diff /tmp/a /tmp/b; rm -f /tmp/a /tmp/b`.

    sh (POSIX)

    bash --posix: ACCEPTED (bash extension). busybox ash: REJECTED. ksh93: ACCEPTED. mksh: ACCEPTED.

  • `function` keyword

    dash

    dash: REJECTED. Only the POSIX form `name() { …; }` works.

    sh (POSIX)

    bash --posix: ACCEPTED (bash extension). busybox ash: REJECTED. ksh93: NOT a synonym — `function name { …; }` has subtly different scoping rules (more like a "ksh function" with `typeset` local-by-default). mksh: like ksh93.

    Always use `name() { …; }`. Works in every sh implementation, has consistent scoping semantics.

  • Performance for tight loops

    dash

    dash: famously fast — startup ~3ms, basic command-processing tighter than bash by 3-4x. Reason Debian boot scripts moved to dash in 2009.

    sh (POSIX)

    busybox ash: nearly as fast as dash (similar Almquist origin). bash --posix: ~10ms startup, ~3-4x slower per command than dash. ksh93: comparable to bash --posix, sometimes faster. mksh: between dash and bash.

    Boot-time scripts get measurable speedup running on dash vs bash --posix — the original motivation. For one-shot scripts that run for seconds, the difference is invisible.

Gotchas when porting between them

  • **"Passes on dash" ≠ "POSIX-portable".** dash is *one* implementation. A script that uses `local` runs fine on dash, busybox ash, and bash --posix, but `shellcheck --shell=sh` flags it as non-POSIX, and a future POSIX-strict shell could break it. Always run `shellcheck --shell=sh script.sh` for portability claims, not "I tested it on Debian".
  • **busybox ash is NOT dash** even though both are POSIX-strict almquist-family shells. busybox ash diverges: it interprets `echo -e` (dash does not), supports `source` (dash does not), supports `read -p` (dash does not), implements `local` (dash does too). Alpine containers run busybox ash; Debian containers run dash. Test on both if your code ships in containers.
  • **`bash --posix` is the "polite bash", not a sh-strict shell.** When `/bin/sh` is symlinked to bash (macOS, RHEL, Fedora, Amazon Linux), it runs `bash --posix` mode — which disables SOME bash extensions but still allows arrays, `[[ ]]`, `<<<`, `${var,,}`, `set -o pipefail`, `function`, `<(…)`. Code that "works on my Mac's /bin/sh" can break catastrophically on a Debian CI runner.
  • **ksh on AIX and OpenBSD is the silent third option.** `/bin/sh` on AIX is `ksh` (ksh88), on OpenBSD it's the OpenBSD fork of pdksh (mksh-like). Both share the bash-ish features `[[ ]]`, `function`, arrays — but with subtly different semantics (ksh's `function name` is local-by-default; `name()` is global-by-default). Porting a Linux script to AIX or OpenBSD without testing is risky.
  • **`dash -n script.sh` is NOT a portability check.** It only checks dash's syntax — anything dash accepts passes, including the dash-extensions (`local`, `kill -%`) that are not POSIX. The only check that catches "POSIX violations regardless of which sh you're on" is `shellcheck --shell=sh script.sh`. Use both: `dash -n` catches dash-rejected syntax (parse errors); `shellcheck --shell=sh` catches POSIX-only-rule violations.

Full references

  • dash
    Debian/Ubuntu `/bin/sh` since 2009. Tiny, fast, POSIX-strict — explains "works in bash, fails in /bin/sh" surprises.
  • sh (POSIX)
    POSIX-strict shell — the lowest common denominator for portable scripts. No arrays, no `[[ ]]`, no process substitution.

Other comparisons