Why this matters
You have written bash for fifteen modules. Sooner or later one of your scripts runs somewhere bash is not the shell: a Debian or Ubuntu system's /bin/sh (which is dash), an Alpine container (BusyBox ash), a FreeBSD server (sh), an old Mac (bash 3.2), a Dockerfile RUN line, a cron job, or a Makefile recipe, all of which use /bin/sh by default. If the script uses bash-only features, it fails there with confusing errors, or worse, runs and does something slightly different. Knowing which features are standard lets you decide deliberately: write portable sh when a script must run anywhere, and use bash freely, with a #!/bin/bash shebang, when it does not.
The concept
POSIX is a standard (IEEE 1003.1) that specifies a common Unix interface, including a shell language and a set of utilities. A shell that implements it is called a POSIX shell, and sh names that language. bash is a POSIX shell plus many extensions; dash is a small, fast shell that implements little beyond POSIX. Features that work in bash but not in plain POSIX sh are called bashisms.
┌──────────────── bash ────────────────┐
│ [[ ]] arrays {1..9} <<< (( )) │
│ ${v//a/b} ${v^^} local* source │
│ ┌────────── POSIX sh ────────────┐ │
│ │ [ ] $( ) $(( )) case for │ │ dash implements roughly
│ │ while functions ${v#p} ${v%p}│ │ this inner box
│ │ "$@" set -e/-u trap . read │ │
│ └────────────────────────────────┘ │
└──────────────────────────────────────┘
* local is not in POSIX, but dash, ash and every common sh support it
The interpreter that runs a script is chosen by its shebang line when you run it as ./script, or by the program you name when you run bash script or sh script. Under the hood, execve reads #!/bin/sh and runs /bin/sh script for you. So:
#!/bin/bashpromises bash and may use bashisms.#!/bin/shpromises POSIX; on Debian that means dash, which will reject bashisms.#!/usr/bin/env bashfinds bash viaPATH, useful where bash lives in/usr/local/bin(BSDs, Homebrew on macOS).
A very common failure is a bash script with a #!/bin/sh shebang. It "works" on a distribution whose /bin/sh is bash (Fedora, RHEL, older macOS) and breaks on Debian or Alpine.
What happens underneath
Try it in the sandbox shell, where /bin/sh is a link to dash:
$ ls -l /bin/sh
lrwxrwxrwx. 1 root root 4 Feb 4 2025 /bin/sh -> dash
$ sh -c '[[ 1 == 1 ]] && echo yes'
sh: 1: [[: not found
$ sh -c 'a=(1 2 3)'
sh: 1: Syntax error: "(" unexpected
$ sh -c 'echo {1..3}'
{1..3}
$ sh -c 'source ./lib.sh'
sh: 1: source: not found
Notice the different kinds of failure. Arrays are a syntax error: dash cannot even parse the script, so nothing runs. [[ parses fine as an ordinary command name and fails only when that line runs, with status 127, so the rest of the script continues with wrong assumptions. Brace expansion does not fail at all: the loop for i in {1..3} runs once, with i set to the literal string {1..3}. Silent misbehavior is the dangerous case.
One more classic: cmd &> file means "both outputs to file" in bash. dash reads it as cmd & (run in the background) followed by > file (an empty redirection that truncates the file). The output goes to the terminal and the file ends up empty. Portable form: cmd > file 2>&1.
Portable replacements
| bashism | POSIX replacement |
|---|---|
[[ $a == "$b" ]] | [ "$a" = "$b" ] (quote everything; = not ==) |
[[ $f == *.txt ]] | case $f in *.txt) ... ;; esac |
[[ $s =~ re ]] | printf '%s\n' "$s" | grep -Eq 're', or expr |
(( n++ )), let | n=$((n + 1)) |
arr=(a b), "${arr[@]}" | the positional parameters: set -- a b then "$@" |
{1..5} | seq 1 5 or a while loop with a counter |
grep x <<< "$s" | printf '%s\n' "$s" | grep x |
echo -e 'a\tb' | printf 'a\tb\n' |
${v//a/b}, ${v:0:3} | sed, cut, expr |
${v^^} | tr '[:lower:]' '[:upper:]' |
source file | . file |
function f { } | f() { } |
&> file | > file 2>&1 |
read -a, mapfile | while read loops, or set -- |
$RANDOM | awk 'BEGIN{srand(); print int(rand()*32768)}' |
set -o pipefail | only in newer shells (dash 0.5.12 has it; POSIX added it in 2024) |
The positional parameters are POSIX sh's only array, and they are enough surprisingly often:
set -- # empty the list
for f in *.log; do
[ -s "$f" ] && set -- "$@" "$f" # append non-empty logs
done
echo "$# non-empty logs"
tar -czf logs.tgz "$@"
Inside a function, "$@" refers to the function's own arguments, so build such a list at the top level or pass it along explicitly.
Testing for portability
- Run the script with dash:
dash script.sh, orsh script.shon Debian.dash -n script.shchecks syntax without running. - Run bash in POSIX mode:
bash --posix script.sh. It changes some behaviors to match the standard but still accepts most extensions, so it is a weaker test than dash. - Tools:
checkbashisms(from Debian's devscripts) and ShellCheck with#!/bin/shflag bashisms statically. Neither is installed in this sandbox, which has no network, so dash is your tester here.
When to bother
Portability has a cost: no arrays, no [[ ]], more quoting and more external commands. Choose:
- Must run as
/bin/sh(installers, container entrypoints on minimal images, init scripts,Makefilerecipes, scripts for BSD or embedded systems): write POSIX, test with dash. - Your own tooling on systems that all have bash: write bash, say so with
#!/bin/bashor#!/usr/bin/env bash, and enjoy the features. A correct shebang is the important part.
Common mistakes
- bash code under a
#!/bin/shshebang. Works on Fedora, fails on Debian:[[: not found,Syntax error: "(" unexpected,Bad substitution. - Running a bash script with
sh script.sh. The command line overrides the shebang;shruns it as dash. ==inside[ ]. bash accepts it, dash says[: a: unexpected operator.- echo with escapes or options.
echo -nandecho -ediffer between shells. Useprintf. - Assuming
localis POSIX. It is not, though every common sh has it. Truly strict environments may lack it.
Exercises
Two quizzes, then two scripts that the checker runs under both bash and dash. Test them yourself in the sandbox with dash script.sh ... before checking.
Recap
- POSIX sh is the portable common core; bash adds extensions, called bashisms.
- On Debian
/bin/shis dash, which rejects bashisms; the shebang (or thesh/bashyou type) decides the interpreter. - Bashisms fail in three ways: syntax errors, "not found" at run time, or silent misbehavior.
- Portable replacements:
[ ]with=,casefor patterns,$(( )),printf,.,set --for lists,> f 2>&1. - Test with
dash script.sh; decide deliberately whether a script needs to be portable.
Going further: read the POSIX "Shell Command Language" specification (search for "pubs.opengroup.org shell command language" on a machine with network). In the sandbox, take a script from an earlier module and run it with dash; fix each error until it runs.