🌍 POSIX sh vs bash

What /bin/sh really is, the bashisms tested side by side on dash, bash and BusyBox, the silent failures, portable replacements and how to test for them

Video: POSIX sh vs bash (Shell Scripting video)
Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
POSIX sh vs bash: writing scripts that run everywhere (and knowing when not to)
dash -n script.sh; shellcheck -s sh script.sh

"It works on my machine" is the oldest sentence in system administration, and shell scripts are where it hurts most. You write a script with #!/bin/sh, test it on your Ubuntu laptop, and it fails in a Docker container on Alpine with "syntax error: unexpected (". Or it runs fine for a year, and then a colleague runs it with sh on another distribution and a counter silently stays at zero.
The cause is that "sh" is a name, not one program. This page shows what sh really is on different systems, which bash features it lacks, how to write scripts that run in all of them, and how to decide whether you should not bother (and simply require bash). Every result here comes from real runs on Ubuntu 24.04: GNU bash 5.2.21, dash 0.5.12 (the Debian and Ubuntu /bin/sh) and BusyBox 1.36.1 (the shell of Alpine and of many small containers). The error messages are quoted exactly.

What "sh" really is

POSIX defines a standard shell language: the "sh" that every Unix system must provide. It is a small language: variables, quoting, if/case/for/while, functions, redirection, $(...) and $((...)). Bash is a much larger shell that contains POSIX sh and adds a long list of conveniences (arrays, [[ ]], ${var//x/y}, process substitution and more). The things bash adds are called bashisms. What the file /bin/sh actually is depends on the system:
  Debian, Ubuntu            dash: small, fast, strict, close to pure POSIX
  RHEL, Fedora, CentOS, SUSE   bash, started under the name sh, so it switches to its POSIX mode (but keeps much of its extra syntax)
  Alpine, many containers   BusyBox ash: small, with some bash extras and some gaps
  macOS                     an old bash 3.2 (or zsh), also in POSIX mode
  FreeBSD, OpenBSD          their own ash/ksh variants
The lab machine:
$ ls -l /bin/sh | awk '{print $9, $10, $11}'
/bin/sh -> dash
$ readlink -f /bin/sh
/usr/bin/dash
$ dpkg -s dash | grep '^Version'
Version: 0.5.12-6ubuntu5
$ busybox 2>&1 | head -1
BusyBox v1.36.1 (Ubuntu 1:1.36.1-6ubuntu3.1) multi-call binary.
$ grep -v '^#' /etc/shells | tr '\n' ' '
/bin/sh /usr/bin/sh /bin/bash /usr/bin/bash /bin/rbash /usr/bin/rbash /usr/bin/dash /usr/bin/tmux
So a script that starts with #!/bin/sh is run by dash here, by bash on RHEL, and by BusyBox on Alpine. If it uses even one bashism it works on some of them and breaks on others.

Which should you use: sh or bash?

There is no shame in either. Choose on purpose:
  Use bash (#!/usr/bin/env bash) when the script is for your own servers, you control what is installed, and you want arrays, [[ ]], set -o pipefail, process substitution and the rest. This is the right answer for most admin scripts, and it makes them shorter and safer.
  Use POSIX sh (#!/bin/sh) when the script must run on unknown systems or minimal ones: installers, container entrypoints and healthchecks (Alpine has no bash unless you add it), init scripts, tiny helper tools, anything shipped to other people's machines, and the scripts your package manager runs. Be strict about it, because the failures happen on somebody else's machine.
  Use Python or another language when the script needs data structures, real parsing, or more than a couple of hundred lines.
The mistake to avoid is the third option in disguise: writing bash and putting #!/bin/sh at the top because it "usually works". It works until the day sh is not bash.

The shebang line

The first line decides which program runs the file. The lab ran the same one-line script under three shebangs and printed bash's version string, which only bash sets:
$ for s in "#!/bin/sh" "#!/bin/bash" "#!/usr/bin/env bash"; do ... ./sb.sh; done
#!/bin/sh              -> via:
#!/bin/bash            -> via: 5.2.21(1)-release
#!/usr/bin/env bash    -> via: 5.2.21(1)-release
With #!/bin/sh the version was empty: this was dash, not bash. #!/usr/bin/env bash finds bash wherever it is installed (on macOS and the BSDs it is not in /bin), and is the usual choice for bash scripts; #!/bin/bash is fine on Linux servers where you know the path. Always name the shell the script was written for. Note that the shebang is used only when you run the file directly (./script.sh): sh script.sh and bash script.sh ignore it, which is how a script written for bash gets run by dash by accident.

The bashisms, tested side by side

Each line below was run with dash and with bash using sh -c. This is the real output. (In the lab, BusyBox ash is listed where it differs in an interesting way.)
  [[ a == a ]]
     dash:  dash: 1: [[: not found
     bash:  works.   BusyBox: works
  arr=(a b c); echo ${arr[1]}
     dash:  dash: 1: Syntax error: "(" unexpected
     BusyBox:  sh: syntax error: unexpected "("
  [ a == a ]
     dash:  dash: 1: [: a: unexpected operator      (POSIX test has only a single =)
  cat <<< "hello"       (here-string)
     dash:  dash: 1: Syntax error: redirection unexpected
     BusyBox:  sh: syntax error: unexpected redirection
  cat <(echo hi)        (process substitution)
     dash:  dash: 1: Syntax error: "(" unexpected
  function f { echo in f; }; f
     dash:  dash: 1: Syntax error: "}" unexpected    (write f() { ...; } instead)
  source /dev/null
     dash:  dash: 1: source: not found                (use the dot: . file)
  v=hello; echo ${v//l/L}
     dash:  dash: 1: Bad substitution                 (BusyBox: works, printing heLLo)
  v=hello; echo ${v^^}
     dash:  dash: 1: Bad substitution                 (case conversion is bash 4)
  name=var; var=value; echo ${!name}
     dash:  dash: 1: Bad substitution                 (indirect expansion)
  set -o pipefail
     dash:  dash: 1: set: Illegal option -o pipefail   (BusyBox accepts it)
  declare -i n=5
     dash:  dash: 1: declare: not found
  let n=5+1
     dash:  dash: 1: let: not found                    (use n=$((5+1)))
  printf "%q\n" "a b"
     dash:  dash: 1: printf: %q: invalid directive
  [ -v x ]
     dash:  dash: 1: [: -v: unexpected operator
  x=abc; [[ $x =~ ^a ]]
     dash:  dash: 1: [[: not found
  echo $((1+2)) $((2**3))
     dash:  dash: 1: arithmetic expression: expecting primary: "2**3"     (no ** in dash; BusyBox printed 8)
  wait -n
     dash:  dash: 1: wait: Illegal option -n
  mapfile -t lines < /dev/null
     dash:  dash: 1: mapfile: not found
  shopt -s nullglob
     dash:  dash: 1: shopt: not found
Things that work everywhere, so do not worry about them: local in functions (not in POSIX, but dash and BusyBox have it), $((1+2)), printf, case, ${var%suffix}, ${var#prefix}, ${#var}, ${var:-default}, here-documents and read -r. dash even accepts read -p "prompt" (no error in the test), but POSIX does not define it, so do not rely on it.
Notice that the first group are loud errors: the script stops, you see the message, you fix it. The next group is worse.

The silent failures: wrong answers instead of errors

Some bashisms are valid POSIX syntax with a different meaning, so there is no error, only a wrong result:
  i=0; ((i++)); echo $i
     dash:  dash: 1: i++: not found
            0
     bash:  1
  In POSIX sh, (( ... )) is a subshell inside a subshell. dash tried to run a command called "i++" and printed "not found", and the counter stayed 0. The script carried on with a wrong value. Use i=$((i+1)).
  echo hi &> out.txt; ...
     dash:  hi
            out.txt: 0 bytes
     bash:  out.txt: 3 bytes
  In dash, "cmd &> file" means "run cmd in the BACKGROUND, then redirect nothing to file": the output went to the terminal and out.txt was created empty. bash redirected both streams. Use cmd > file 2>&1.
  echo -e "a\tb"
     dash:  -e a<TAB>b      (it printed the two letters -e, and interpreted the escape anyway)
     bash:  a<TAB>b
  Use printf 'a\tb\n'. echo with options or backslashes is not portable at all (see the Bash Gotchas page).
  echo {1..3}
     dash:  {1..3}
     bash:  1 2 3
  Brace expansion does not exist in POSIX sh: the text is printed unchanged (BusyBox did the same). Use seq 1 3.
  echo "[$RANDOM] [$BASHPID] [$OSTYPE]"
     dash:  [] [] []          (the variables are simply empty; bash fills in a number, a PID and "linux-gnu")
  trap "echo err" ERR; false; echo done
     dash:  trap: ERR: bad trap
            done
     bash:  err
            done
  dash has no ERR trap: it complained about the name and the script continued with no error handling. A script that relies on ERR for cleanup or reporting simply has none under sh. Use EXIT, and check statuses.
The lesson: one dash run is worth reading every line of the output of your script, not just the exit code. And never trust "it printed no error".

Bash pretending to be sh

When bash is started under the name sh (as on RHEL, where /bin/sh is a link to bash), it turns on POSIX mode. This is NOT the same as the strict POSIX shell, and testing in it proves little:
$ ln -sf "$(command -v bash)" bin/sh
$ ./bin/sh -c 'echo "BASH_VERSION set: ${BASH_VERSION:+yes}"; shopt -o posix; arr=(a b); echo "array still works as sh: ${arr[1]}"; echo -e "a\tb"; type source | head -1'
BASH_VERSION set: yes
posix          	on
array still works as sh: b
a	b
source is a special shell builtin
Posix mode was on, yet arrays still worked, echo -e still interpreted its escape, and source was still available. The same is true of bash --posix:
$ bash --posix -c 'a=(1 2); echo "array works in posix mode: ${a[1]}"; [[ x == x ]] && echo "[[ works too"'
array works in posix mode: 2
[[ works too
So a script that "works with sh" on a RHEL machine can still contain bashisms that fail on Ubuntu or Alpine. To prove portability you must test with a real strict shell: dash, and BusyBox ash.

Portable replacements

The bashisms have plain equivalents that work in every POSIX shell. The ones to know:
  [[ $x == a* ]]             case $x in a*) ... ;; esac               patterns in case: * ? [a-z] and alternatives with |
  [[ $x =~ regex ]]          printf '%s\n' "$x" | grep -Eq 'regex'    or case for simple shapes; expr for old systems
  [ a == b ]                 [ "$a" = "$b" ]                          single = in [ ] and always quote
  [ -v var ]                 [ -n "${var+set}" ]                      true when var is set, even if empty
  arrays                     set -- a b c  (use "$@"), or lines of text and while IFS= read -r      keep a list in the positional parameters
  ${v//x/y}                  printf '%s\n' "$v" | sed 's/x/y/g'
  ${v^^}  ${v,,}             printf '%s\n' "$v" | tr 'a-z' 'A-Z'
  ${!name}                   eval "value=\$$name"                    carefully: name must be checked first
  <<< "$text"                printf '%s\n' "$text" | cmd              or a here-document
  <(cmd)                     cmd > "$tmp"; other "$tmp"               a temp file from mktemp, removed in a trap
  function f { ...; }        f() { ...; }
  source file                . ./file                                 the dot needs a path (./file) or it searches PATH
  ((i++)) / let i=i+1        i=$((i+1))
  echo -e "a\tb"             printf 'a\tb\n'
  cmd &> file                cmd > file 2>&1
  {1..$n}                    seq 1 "$n"  or  i=1; while [ "$i" -le "$n" ]; do ...; i=$((i+1)); done
  $RANDOM                    awk 'BEGIN{srand(); print int(rand()*32768)}'
  $'\t'                      tab=$(printf '\t')
  read -p "Q: " a            printf 'Q: '; read -r a
  declare / typeset          plain variables; readonly NAME=value for constants
  mapfile / readarray        while IFS= read -r line; do ...; done < file
  pipefail                   no equivalent in older shells: avoid pipelines whose first part matters, or write to a temp file and check each step
  trap ... ERR               check statuses with || and use trap ... EXIT
  shopt, [[ -v ]], coproc    no equivalent: restructure
  wait -n                    wait for a specific PID

A POSIX script, proved on three shells

This script uses only the replacements above (the first line is the shebang). It was run unchanged by dash, bash and BusyBox sh:
$ cat portable.sh
#!/bin/sh
# POSIX replacements for common bashisms
x="notes.txt"
case $x in *.txt) echo "case glob: txt file" ;; esac
case $x in [a-z]*) echo "case class: starts with a letter" ;; esac
v="hello world"
echo "strip suffix: ${x%.txt}  strip prefix: ${x#not}  length: ${#v}"
echo "replace (sed): $(printf '%s\n' "$v" | sed 's/o/0/g')"
i=0; i=$((i+1)); i=$((i+1)); echo "counter: $i"
set -- one "two words" three
for a in "$@"; do printf '<%s>' "$a"; done; echo
printf '%s\n' "line one" "line two" | while IFS= read -r l; do printf 'read: %s\n' "$l"; done
cmd_out=$(printf 'a b c' | tr ' ' '\n'); printf '%s\n' "$cmd_out" | wc -l | tr -d ' '
[ "$x" = "notes.txt" ] && echo "single = works in [ ]"
[ -n "${undefined+set}" ] || echo "undefined is not set (portable -v test)"
tab=$(printf '\t'); printf 'a%sb\n' "$tab"
printf 'prompt: '; read -r answer < /dev/null || true; echo
f() { echo "function without keyword: $1"; }; f ok
echo hi > both.txt 2>&1; echo "redirect both: $(wc -c < both.txt) bytes"
if [ -r ./lib.sh ]; then . ./lib.sh; else echo "lib.sh not found, using defaults"; fi
$ dash portable.sh
case glob: txt file
case class: starts with a letter
strip suffix: notes  strip prefix: es.txt  length: 11
replace (sed): hell0 w0rld
counter: 2
<one><two words><three>
read: line one
read: line two
3
single = works in [ ]
undefined is not set (portable -v test)
a	b
prompt:
function without keyword: ok
redirect both: 3 bytes
lib.sh not found, using defaults
$ bash portable.sh      (identical output)
$ busybox sh portable.sh      (identical output)
All three printed the same 16 lines and exited 0. Notes on the contents: "strip prefix: ${x#not}" removed the three letters "not" from "notes.txt", leaving "es.txt" (a prefix removal is by characters, not by words, and it is POSIX). ${var%suffix} and ${var#prefix} (shortest match) and ${var%%suffix} and ${var##prefix} (longest match) are the portable string tools; the sed or tr pipelines cover the rest. The last line is not decoration, as the next section explains.

Special builtins: when a POSIX shell exits instead of continuing

POSIX says that when certain built-in commands (the "special" ones: ., set, export, exit, eval, trap, shift and others) fail, a non-interactive shell must EXIT. bash in its normal mode does not follow this and carries on; dash and BusyBox do. A missing file for the dot (source) command is the classic:
$ for sh in dash bash; do echo "--- $sh"; $sh -c '. ./missing.sh; echo "script continued"'; echo "[exit $?]"; done
--- dash
dash: 1: .: cannot open ./missing.sh: No such file
[exit 2]
--- bash
bash: line 1: ./missing.sh: No such file or directory
script continued
[exit 0]
dash stopped the whole script with status 2 and never printed "script continued"; bash complained and went on. BusyBox also exited with status 2 on the first version of the portable script above. The guarded form never fails:
$ for sh in dash bash; do ...; $sh -c 'if [ -r ./missing.sh ]; then . ./missing.sh; else echo "missing.sh not found, using defaults"; fi'; done
dash missing.sh not found, using defaults
bash missing.sh not found, using defaults
The same rule explains an earlier result: "set -o pipefail" under dash printed "set: Illegal option -o pipefail" and nothing after it, not even the echo that followed: a bad option to the special builtin set ended the script. So under sh, an unsupported set option is not a warning, it is the end. Check what the target shells support before you put anything in a header.

Testing for portability

No single tool proves portability. Use several:
  Run the script with the strictest shell you target: dash script.sh (sudo apt install dash if missing), and busybox sh script.sh for containers. Run the real script with real arguments, including the error paths.
  dash -n script.sh parses the file without running it, which finds syntax bashisms:
$ printf '#!/bin/sh\narr=(a b)\necho done\n' > bad_syntax.sh
$ dash -n bad_syntax.sh; echo "[exit $?]"
bad_syntax.sh: 2: Syntax error: "(" unexpected
[exit 2]
  But -n cannot see commands that are only missing at run time. This script uses source and [[, both unavailable in dash, yet it passes the syntax check:
$ printf '#!/bin/sh\nsource ./lib.sh\n[[ -f x ]] && echo hi\necho done\n' > bad_runtime.sh
$ dash -n bad_runtime.sh; echo "[exit $?]"
[exit 0]
$ dash bad_runtime.sh; echo "[exit $?]"
bad_runtime.sh: 2: source: not found
bad_runtime.sh: 3: [[: not found
done
[exit 0]
  The syntax check said everything was fine. Running it showed two "not found" errors, and the script still ended with exit status 0 and "done": a script that fails on a missing command in the middle can end up reporting success.
  shellcheck -s sh script.sh checks against POSIX rules and flags bashisms (SC2039 and SC3xxx codes such as "in POSIX sh, [[ ]] is undefined"). ShellCheck is not installed on the lab machine, so this one was not run here; install it with sudo apt install shellcheck. It also reads the shebang: a script that says #!/bin/sh is checked as POSIX automatically.
  checkbashisms (package devscripts) greps for bashisms: checkbashisms script.sh. Not installed here either.
  bash --posix and running bash as sh are NOT checkers (see above).
  Put dash and busybox in your CI, or run the script in a container for each target (docker run --rm -v "$PWD":/w alpine sh /w/script.sh), so you find out before your users do.

Beyond the shell: commands that differ

Even a perfectly POSIX script calls other programs, and those differ between systems. These differences are well known but were not tested in this lab (it only has GNU tools), so treat them as a checklist rather than as measured results:
  GNU vs BSD/macOS: sed -i needs a backup suffix argument on macOS (sed -i '' ...); date -d "yesterday" is GNU only; readlink -f and realpath are missing on old macOS; grep -P (Perl regex) is GNU only; stat uses a different format option; xargs -r and find -printf are GNU extensions; sort -h, ls --color, cp -a and df -P vary; tar, ps and sed options differ.
  BusyBox: many tools are cut down (fewer options in find, grep, sed, ps, tar, date), which breaks scripts that use GNU extensions.
  Stick to POSIX options of those commands when you want portability, or detect the system (uname -s) and branch. When you need GNU features on macOS, install them (brew install coreutils gnu-sed) and call the g-prefixed names.

Requiring bash on purpose

If you decide on bash, say so and enforce it, so a wrong interpreter fails at once with a clear message instead of in the middle:
  #!/usr/bin/env bash
  if [ -z "${BASH_VERSION:-}" ]; then echo "this script needs bash, not sh" >&2; exit 1; fi
  if (( BASH_VERSINFO[0] < 4 )); then echo "bash 4 or newer is required" >&2; exit 1; fi
The first check works because it is written in POSIX syntax and so it can be PARSED and run by sh; the second line uses (( )) and is bash only, so it comes after the first. Bash 4 or newer is needed for associative arrays (declare -A), mapfile, ${v^^} and some more; macOS ships bash 3.2, because of the license, so a script that uses those features fails on a stock Mac unless bash is installed from Homebrew. State the minimum version in the script header and the README.
Also decide how scripts are launched: ./script.sh runs the shebang; bash script.sh forces bash; sh script.sh forces sh, and breaks a bash script. In cron, systemd units, Dockerfiles and CI, write the interpreter explicitly (ExecStart=/usr/bin/bash /opt/job.sh, CMD ["bash", "/app/run.sh"]) so no one has to guess.

Common problems

"Syntax error: "(" unexpected" or "unexpected (" in a container or on Debian/Ubuntu   an array, process substitution or (( )) in a script run by dash or BusyBox: change the shebang to bash and install bash, or rewrite in POSIX
"[[: not found"                                  the script was started with sh or dash: use bash, or replace [[ ]] with case or [ ]
"source: not found"                              dash has no source: use . ./file, or run with bash
"Bad substitution"                                ${v//x/y}, ${v^^}, ${!name}, ${v:0:3} (substring) in dash: use sed, tr, cut or case, or require bash
"set: Illegal option -o pipefail" and the script stops   dash exits on a bad set option: remove it, or require bash
It runs but a counter or a flag is wrong         ((i++)), let, &> or echo -e under sh: silently different. Run it with dash and read all the output
The script works from the terminal but not from cron or a Dockerfile   a different /bin/sh or no bash at all: name the interpreter explicitly and test in that environment
Works on RHEL, fails on Ubuntu                    bash running as sh accepts bashisms (POSIX mode is lenient): test with dash
Script stops with exit status 2 at a . line      the dot of a missing file kills dash and BusyBox: test with [ -r file ] first
"/bin/bash: bad interpreter" or "env: bash: No such file"    bash is not installed (Alpine, minimal images): apk add bash, or write POSIX
"^M: bad interpreter"                            Windows line endings in the shebang line: dos2unix script.sh
sed -i or date -d works on Linux but not on macOS   GNU vs BSD options: use portable forms or install the GNU tools

Tips

"sh" is not bash. Decide per script which one you are writing and write the right shebang for it.
For your own servers, use bash and say so (#!/usr/bin/env bash). For installers, containers and anything you ship, write strict POSIX sh.
Test a POSIX script with dash and BusyBox, not with bash --posix or on a RHEL machine.
Read the whole output, not the exit code: the worst bashisms (((i++)), &>, echo -e) give wrong answers without an error.
Guard every dot-source with [ -r file ], and keep the header of a POSIX script free of set -o pipefail and set -E.
Use printf instead of echo, case instead of [[ ]], $((i+1)) instead of ((i++)), and f() { } instead of function f.
Lint with shellcheck, check syntax with dash -n, and keep a container per target in your CI.
When the portable version becomes unreadable, that is the time to require bash, or to switch to Python.