Skip to content
shellmap

sh (POSIX) vs Bash

`sh` is POSIX — the portable minimum. Bash is sh + 20 years of GNU extensions. Knowing the difference is the difference between "works on Alpine" and "needs a Dockerfile rewrite".

Summary

`sh` is a specification (POSIX 2017), not a single binary. On Debian/Ubuntu `/bin/sh` is `dash`; on Alpine it's `busybox ash`; on macOS it's `bash --posix`; on RHEL it's `bash --posix`. They all implement the POSIX subset, but each adds its own non-POSIX extras.

Bash is a specific implementation that started as a POSIX `sh` clone in 1989 and grew arrays, `[[ ]]`, process substitution `<()`, brace expansion `{1..10}`, here-strings `<<<`, `local`/`declare`/`typeset`, `${var,,}` case conversion, regex match `=~`, `pipefail`, named pipes via `|&`, and thousands of other niceties.

If your script starts with `#!/bin/sh` and uses any of those extras, it will run on YOUR machine (where `bash` symlinks to `/bin/sh`) and fail in production (where `dash` or `ash` does). Either change the shebang to `#!/usr/bin/env bash` and depend on bash being installed, or stick to POSIX. There is no "almost POSIX".

Syntax & semantic differences

  • Conditional tests

    sh (POSIX)

    `[ "$a" = "$b" ]` — single brackets. String compare uses `=` (NOT `==`). Numeric uses `-eq`/`-lt`/`-gt`. No regex match.

    Bash

    `[[ $a == $b ]]` — double brackets. Doesn't need quoting around variables, supports `==`/`!=`/`<`/`>` for string compare, `=~` for regex, `&&`/`||` inside the test.

    The single biggest portability trap. `[[` is bash-only; POSIX `sh` errors with `[[: command not found`. Always use `[ ]` in scripts that target `/bin/sh`. `[ ]` has its own quoting requirements — `[ -z $var ]` BREAKS when `$var` is empty (becomes `[ -z ]`); always `[ -z "$var" ]`.

  • Arrays

    sh (POSIX)

    No arrays. POSIX has only scalar strings and positional parameters `$1 $2 ... $@`. Workarounds: space-separated strings + `IFS` parsing (fragile), or repeated `eval`.

    Bash

    `arr=(a b c)`, index with `${arr[2]}`, length `${#arr[@]}`, slice `${arr[@]:1:2}`, append `arr+=(d)`. Associative arrays via `declare -A` (bash 4+).

    Porting a bash array script to POSIX usually means rewriting the data model — push the work to an external program (`awk`, `python`) rather than fighting POSIX.

  • Local variables in functions

    sh (POSIX)

    No `local`. All variables are global. Idiomatic POSIX uses a subshell: `( my_var=foo; ... )` — assignments inside the parens don't leak.

    Bash

    `local var=value` inside a function scopes the variable to that call. `declare`/`typeset` are bash equivalents with extra attribute flags.

    Many "POSIX" shells (dash, ksh, busybox ash) actually DO support `local` as an extension — but it's not in the spec. Truly portable scripts use the subshell trick.

  • Process substitution

    sh (POSIX)

    Not available. Workaround: write to a temp file, then read it. `cmd1 > /tmp/a; diff /tmp/a <(cmd2)` becomes `cmd1 > /tmp/a; cmd2 > /tmp/b; diff /tmp/a /tmp/b; rm -f /tmp/a /tmp/b`.

    Bash

    `diff <(cmd1) <(cmd2)` — bash creates named-pipe fds `/dev/fd/63` and `/dev/fd/64` so each subshell's output appears as a file. Also `>(cmd)` for sending output to a process as a "file".

  • Here-strings

    sh (POSIX)

    Not available. Use here-doc instead: `cmd <<EOF\ntext\nEOF`.

    Bash

    `cmd <<< "text"` — single-line input from a string. Common idiom for feeding a one-liner into `grep`, `bc`, `awk`.

  • Brace expansion

    sh (POSIX)

    Not available. `mkdir {a,b,c}` literally creates one directory called `{a,b,c}`.

    Bash

    `mkdir {a,b,c}` creates three directories `a b c`. `echo {1..10}` prints `1 2 3 4 5 6 7 8 9 10`. `cp file{,.bak}` is the famous "backup" idiom.

  • String manipulation

    sh (POSIX)

    POSIX parameter expansion: `${var%suffix}`, `${var#prefix}`, `${var:-default}`, `${var:?error}`. Available in both. No case conversion, no replacement.

    Bash

    Bash adds `${var,,}` (lower), `${var^^}` (upper), `${var/pattern/replace}` (substitution), `${var:offset:length}` (substring — POSIX-ish but not universal).

  • Pipefail

    sh (POSIX)

    `set -e` aborts on error but does NOT see pipeline failures. `false | true` returns 0 — bug-prone for `cmd | grep`.

    Bash

    `set -o pipefail` makes the pipeline exit with the rightmost non-zero status — `false | true` returns 1. Combine with `set -eu` for the bash strict-mode trio.

  • Function syntax

    sh (POSIX)

    `name() { commands; }` — the POSIX form. The `function` keyword is NOT in POSIX.

    Bash

    Both `function name { ... }` and `name() { ... }` work. The `function` keyword form does NOT allow `()`, and behaves slightly differently around DEBUG traps.

    Always use `name() { ... }` for portability — works in every shell including pure POSIX.

  • Built-ins beyond POSIX

    sh (POSIX)

    `type`, `command`, `read`, `cd`, `pwd`, `set`, `unset`, `export`, `trap`, `eval`, `exec`. That's essentially it.

    Bash

    Bash adds `mapfile`/`readarray`, `printf -v`, `coproc`, `caller`, `compgen`, `bind`, `dirs`/`pushd`/`popd`, `let`, `(( ))` arithmetic. Many of these have no portable equivalent.

Gotchas when porting between them

  • **Debian/Ubuntu `/bin/sh` has been `dash` since 2009** (Ubuntu) / 2011 (Debian). The classic "works on my machine" trap: developer's `/bin/sh` is bash (macOS), CI runs Debian. Every `#!/bin/sh` script gets the strict POSIX interpretation in CI. Fix the shebang to `#!/usr/bin/env bash` if you depend on bash features.
  • Alpine Linux uses `busybox ash` for `/bin/sh` — even stricter than dash. Common breakage: `echo -e "a\nb"` prints literally on busybox (which is POSIX-correct — `echo -e` is non-POSIX). Use `printf "a\nb\n"` instead, which works everywhere.
  • Running `bash --posix` doesn't actually make bash POSIX-strict — it disables a small subset of bash extensions but still lets through arrays, `[[ ]]`, and others. The only reliable way to detect POSIX violations is `shellcheck --shell=sh script.sh`, which lints against actual POSIX rules.
  • `ksh` (Korn shell) sits between sh and bash. AIX, Solaris, and OpenBSD use ksh as their default; ksh supports arrays + `[[ ]]` like bash but with different syntax for some features (`print` vs `echo`, `${.sh.version}`). If your script needs to run on AIX, target ksh, not bash.
  • The `#!/bin/sh` shebang line is read by the kernel — `env`-based shebangs (`#!/usr/bin/env sh`) work the same way but allow PATH-based discovery. Both are POSIX-conformant; pick one project-wide convention.

Full references

Other comparisons