Portability and Debugging · about 45 min

POSIX sh and bashisms

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:

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

bashismPOSIX 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++ )), letn=$((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, mapfilewhile read loops, or set --
$RANDOMawk 'BEGIN{srand(); print int(rand()*32768)}'
set -o pipefailonly 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

When to bother

Portability has a cost: no arrays, no [[ ]], more quoting and more external commands. Choose:

Common mistakes

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

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.

Exercises

Exercise 1 · quiz

Spot the bashisms

Which of these lines will not work in a strict POSIX shell such as dash? Select all that apply.

Answer
  • if [[ $answer == y* ]]; then echo yes; fi

  • nums=(1 2 3)

  • for i in {1..3}; do echo $i; done

  • grep -c x <<< "$text"

[[ ]], arrays, brace expansion and here-strings are bash (and ksh/zsh) extensions. In dash, [[ is simply an unknown command ([[: not found), nums=(1 2 3) is a syntax error, {1..3} stays the literal word {1..3}, and <<< is a syntax error. Single-bracket [ with = and command substitution $( ) are POSIX and work everywhere.

Exercise 2 · quiz

Two shells, one echo

The line echo 'a\tb' (single quotes, a backslash and a t between a and b) is run once by bash and once by dash. What happens?

Answer
  • bash prints a, backslash, t, b; dash prints a, a tab, b.

The single quotes stop both shells from touching the backslash, so echo receives the same four characters in both cases. The difference is in the echo builtins: bash's echo only interprets escapes with -e, while dash's echo always interprets them (and prints -e as text). POSIX leaves backslashes in echo undefined. That is why portable scripts print anything non-trivial with printf, whose behavior is fully specified: printf 'a\tb\n'.

Exercise 3 · write code

Port a script to POSIX sh

The starter is a working bash script. Rewrite it so that it runs correctly under dash (POSIX sh) and still produces exactly the same output. For ana "bo b" it prints:

1: ANA (3 chars)
2: BO B (4 chars)

and with no arguments it prints usage: script.sh NAME... to standard error and exits with status 2.

Requirements:

The checker runs your script with bash (the cases) and with dash (the tests).

File: script.sh

Starting point

#!/bin/bash
if [[ $# -eq 0 ]]; then
  echo "usage: script.sh NAME..." >&2
  exit 2
fi
names=("$@")
for i in "${!names[@]}"; do
  n=${names[i]}
  echo "$((i + 1)): ${n^^} (${#n} chars)"
done

Example with arguments ana bo b

expected output

1: ANA (3 chars)
2: BO B (4 chars)

Example

expected output

(nothing)
Hint 1

"$@" is POSIX: loop with for n in "$@" and count with i=$((i + 1)) (arithmetic expansion is POSIX too).

Hint 2

Uppercase with upper=$(printf '%s' "$n" | tr '[:lower:]' '[:upper:]'). ${#n} (length) is POSIX.

Hint 3

Replace [[ $# -eq 0 ]] with [ "$#" -eq 0 ], and change the first line to #!/bin/sh.

Reference solution
#!/bin/sh
if [ "$#" -eq 0 ]; then
  echo "usage: script.sh NAME..." >&2
  exit 2
fi
i=0
for n in "$@"; do
  i=$((i + 1))
  upper=$(printf '%s' "$n" | tr '[:lower:]' '[:upper:]')
  echo "$i: $upper (${#n} chars)"
done

Exercise 4 · write code

printf instead of echo -e

Write a POSIX sh script (first line #!/bin/sh) that prints a two-column report of its arguments, which come in pairs NAME VALUE. Each line is the name, a tab character, then the value. After the pairs print a line total: N where N is the number of pairs. For cpu 93 disk 71 the output is

cpu<TAB>93
disk<TAB>71
total: 2

(where <TAB> stands for one real tab character). If the number of arguments is odd, print script.sh: arguments must come in pairs to standard error and exit with status 2 before printing anything. Use printf, shift and a while loop; the checker runs the script with dash too.

File: script.sh

Starting point

#!/bin/sh
echo -e "name\tvalue"

Example with arguments cpu 93 disk 71

expected output

cpu	93
disk	71
total: 2

Example with arguments cpu 93 disk

expected output

(nothing)
Hint 1

printf '%s\t%s\n' "$1" "$2" prints a name, a tab and a value, the same in every shell.

Hint 2

Loop with while [ "$#" -gt 0 ]; do ...; shift 2; done.

Hint 3

Odd check: [ $(($# % 2)) -ne 0 ].

Reference solution
#!/bin/sh
if [ $(($# % 2)) -ne 0 ]; then
  echo "script.sh: arguments must come in pairs" >&2
  exit 2
fi
pairs=0
while [ "$#" -gt 0 ]; do
  printf '%s\t%s\n' "$1" "$2"
  pairs=$((pairs + 1))
  shift 2
done
printf 'total: %d\n' "$pairs"