opscape

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}]"'
output
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
output
/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 ="
output
/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"
outputexit 2
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
outputexit 2
/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'
output
$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}"
outputexit 2
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

lib.sh
greet() { echo "hello from lib.sh"; }
source lib.sh
echo "source returned $?"
. ./lib.sh && greet
. lib.sh
echo "not reached"
outputexit 2
/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}]"
output
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
output
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"
output
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: $?"
output
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.sh checks syntax only, sh script.sh for real.
  • shellcheck reads the shebang and warns about bash features in #!/bin/sh scripts (SC3010: In POSIX sh, [[ ]] is undefined and the like).
  • checkbashisms, from Debian's devscripts package, 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