Bash scripting guide · part 32 of 32
POSIX sh vs bash
The bashisms that break when a script runs under dash or another POSIX sh, the exact errors dash prints, and the portable replacement for each.
#!/bin/sh promises that the script needs only a POSIX shell. On Debian and Ubuntu,
/bin/sh is dash, a small, fast shell that implements POSIX and very little else. On
Alpine it is BusyBox ash. On macOS it is bash running in POSIX mode, which still accepts
most bash syntax, so a script developed on a Mac can pass every test there and then fail on
a Debian server or in a container.
Every example on this page runs under dash 0.5.12, Debian 13's /bin/sh (marked "sh" on the
example), so the errors are the real ones. The fix is always one of two things: change the
shebang to #!/usr/bin/env bash (or #!/bin/bash) and keep the bash features, or rewrite the
line portably. Both are fine; claiming sh while writing bash is the bug.
What /bin/sh is here
ls -l /bin/sh
sh -c 'echo "BASH_VERSION is [${BASH_VERSION}]"'
bash -c 'echo "BASH_VERSION is [${BASH_VERSION}]"'
lrwxrwxrwx 1 root root 4 Feb 4 2025 /bin/sh -> dash BASH_VERSION is [] BASH_VERSION is [5.2.37(1)-release]
/bin/sh is a symlink to dash, which does not set BASH_VERSION. Testing that
variable is a quick way for a script to find out whether it is really running in bash.
[[ ]] is not a command dash knows
[[ is a bash keyword. Dash sees it as an ordinary command name, looks for a program called
[[ in PATH, and does not find one. That is a runtime error, not a syntax error, so the
script keeps going, which is worse.
[[ fails quietly and takes the else branch
if [[ -f todo.txt ]]; then
echo "found it"
else
echo "no todo list"
fi
/bin/sh: 1: [[: not found no todo list
The file exists, yet the script printed the else branch. [[: not found made the
condition fail with status 127, and if treats any non-zero status as false. The
portable test is [ -f todo.txt ]. For pattern matching, which [ ] cannot do, use
case: case $f in *.csv|*.tsv) ...;; esac takes the same glob patterns as
[[ $f == *.csv ]] and works in every shell.
== inside [ ] is a bash extension
x=abc
[ "$x" == abc ] && echo "matched with =="
[ "$x" = abc ] && echo "matched with ="
/bin/sh: 2: [: abc: unexpected operator matched with =
Dash's [ knows only = for string equality and reports == as an unexpected operator.
Bash's [ accepts both, which is why this slips through. Always write a single = inside
[ ].
Arrays, function and other syntax errors
Some bashisms are not just unknown commands but invalid syntax for dash. A non-interactive shell aborts on a syntax error with status 2, and because dash parses one complete command (one line, or a whole compound command) at a time, the lines before it have already run.
Arrays do not exist in POSIX sh
echo "before"
hosts=(web01 web02)
echo "never printed"
before
/bin/sh: 2: Syntax error: "(" unexpected The first line ran; the array assignment is a syntax error and ends the script. POSIX sh
has exactly one array, the positional parameters: set -- web01 "db 01" fills $1 and
$2, and for h in "$@" loops over them with spaces preserved. Inside a function,
set -- changes only the function's own arguments.
The function keyword
function greet { echo "hello"; }
greet
/bin/sh: 1: Syntax error: "}" unexpected
Dash does not know function, so it tries to run it as a command and then chokes on the
braces. The POSIX form greet() { echo "hello"; } works in every shell, including bash,
so there is no reason to use the keyword at all.
Strings: $'...', echo -e, ${var:0:2}, ${var^^}
These are where portable scripts most often go subtly wrong, because some of them do not fail; they print something different.
$'...' and echo -e print the wrong thing
echo $'tab:\there'
echo -e "tab:\there"
echo "tab:\there"
printf 'tab:\there\n'
$tab: here -e tab: here tab: here tab: here
$'...' is not special in dash, so the $ is printed and the rest is an ordinary quoted
string. Dash's echo has no -e option and prints it as text, but it interprets
backslash escapes always, which is why the plain third line produced a tab (bash's
echo would print \t). printf behaves the same everywhere: use it whenever the output
contains a backslash or might start with -.
Substrings and case conversion
x=release-2.4
echo "prefix removal is fine: ${x#release-}"
echo "substring: ${x:0:7}"
prefix removal is fine: 2.4 /bin/sh: 3: Bad substitution
The POSIX expansions (${x#...}, ${x%...}, ${x:-default}, ${#x}) all work in dash.
${x:0:7} does not, nor do ${x^^}, ${x//a/b} or ${PIPESTATUS[0]}; dash rejects each
with "Bad substitution" and exits. Use cut -c1-7, tr or sed instead, or bash.
Builtins: source, local, let
source is a bash synonym for the POSIX . command. There is a second difference that
bites even people who use .: when the file name has no slash, POSIX . searches only
PATH, not the current directory. Bash also looks in the current directory unless it is in
POSIX mode.
source is not found, and . without a path searches PATH
greet() { echo "hello from lib.sh"; }
source lib.sh
echo "source returned $?"
. ./lib.sh && greet
. lib.sh
echo "not reached"
/bin/sh: 1: source: not found source returned 127 hello from lib.sh /bin/sh: 4: .: lib.sh: not found
source is an unknown command (127) and the script carries on. . ./lib.sh, with an
explicit path, works. . lib.sh is not found because the current directory is not in
PATH, and since . is a special builtin, its failure ends a POSIX shell script. Write
. ./lib.sh, or build the path from the script's directory.
local works in dash, even though POSIX does not define it
count() { local n=0; for x in "$@"; do n=$((n + 1)); done; echo "$n args"; }
count a b c
echo "n outside: [${n-unset}]"
3 args n outside: [unset]
local is not in the POSIX standard, but dash, BusyBox ash and every other /bin/sh you
are likely to meet support it, and Debian policy allows it in sh scripts. It is the
safest of the non-POSIX features. let and ((...)) are not available; use
n=$((n + 1)), which POSIX does define.
Brace ranges, here-strings and &>
{1..3} is not expanded
for i in {1..3}; do echo "item $i"; done
echo "---"
i=1
while [ "$i" -le 3 ]; do echo "item $i"; i=$((i + 1)); done
item {1..3}
---
item 1
item 2
item 3 Dash has no brace expansion, so the loop ran once with the literal word {1..3}. No
error, just a wrong result. Count with a while loop and arithmetic expansion, or with
seq 1 3 where coreutils is available.
&> starts a background job in sh
echo "hello" &> out.txt
wait
echo "out.txt has $(wc -c < out.txt) bytes"
hello out.txt has 0 bytes
Dash reads echo "hello" & as a background job, then > out.txt as a separate empty
command that just truncates the file. So hello went to the terminal and the file is
empty. The portable spelling is > out.txt 2>&1. The here-string <<< fails more loudly:
dash reports Syntax error: redirection unexpected.
set -o pipefail
For a long time pipefail was the textbook example of something sh could not do: many dash
builds, still common on long-term-support systems and in container images, answer
set: Illegal option -o pipefail. POSIX added pipefail in its 2024 edition, and the dash
0.5.12 in Debian 13 accepts it.
pipefail in Debian 13's dash
false | true
echo "without pipefail: $?"
set -o pipefail
false | true
echo "with pipefail: $?"
without pipefail: 0 with pipefail: 1
It works: the pipeline's status becomes 1 once the option is set. Do not rely on it if the
script must also run on older systems, where /bin/sh may reject the option and, as a
special builtin failing, abort the script. Test for it with
(set -o pipefail) 2>/dev/null && set -o pipefail.
Checking a script for bashisms
- Run it under dash:
sh -n script.shchecks syntax only,sh script.shfor real. shellcheckreads the shebang and warns about bash features in#!/bin/shscripts (SC3010: In POSIX sh, [[ ]] is undefinedand the like).checkbashisms, from Debian'sdevscriptspackage, does the same job with a simpler list of patterns.
Quick map of replacements:
| Bash | POSIX sh |
|---|---|
[[ $a == $b ]] |
[ "$a" = "$b" ] |
[[ $f == *.txt ]] |
case $f in *.txt) ... ;; esac |
arr=(a b), "${arr[@]}" |
set -- a b, "$@" |
function f { } |
f() { } |
echo -e, $'\t' |
printf |
source file |
. ./file |
{1..5} |
i=1; while [ "$i" -le 5 ]; ... |
cmd &> f |
cmd > f 2>&1 |
<<< "$s" |
printf '%s\n' "$s" \| |
$(< file) |
$(cat file) |
((i++)) |
i=$((i + 1)) |
Related
testEvaluate a condition about files, strings or integers and return 0 for true or 1 for false.printfPrint text and values through a format string, with precise control over spacing, numbers and escapes.echoPrint its arguments separated by spaces, followed by a newline.sourceRun the commands in a file inside the current shell, so its variables, functions and directory changes stay in effect.- The shebang lineWhat the kernel does with #!, why
#!/usr/bin/env bashand#!/bin/bashboth exist, and the ways a shebang line breaks. - Test operatorsEvery file, string and number test for [ ] and [[ ]], pattern and regex matching, and the traps like
[ $a > $b ]creating a file. - The case statementMatching one value against glob patterns with case, alternatives with |, the default branch, fall-through with ;& and ;;&, and parsing options with case.
- Brace expansionGenerate lists of words with {a,b,c} and sequences with {1..10} and {01..10..2}, why {1..$n} does not work, and how braces differ from globs.
- Here-documents and here-stringsFeed multi-line text to a command with <