Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Bash & the POSIX Shell  Module 14 – Portability: POSIX sh, versions and platforms

POSIX sh vs Bash: spotting and fixing bashisms

Find the Bash-only features POSIX sh rejects, rewrite them, test the result with dash and ShellCheck, and decide when /bin/sh belongs on the first line.

  • Intermediate
  • 15 minutes
  • Examples run with Bash 5.3.20, dash 0.5.13.5, shellcheck 0.11.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Identify Bash-only features in a script
  • Rewrite them in POSIX sh and test with dash
  • Describe three ways dash behaves differently
  • Decide when #!/bin/sh is the right first line

Before you start

A script that starts with #!/bin/sh runs well on your Mac and goes wrong on an Ubuntu server. Twelve Bash-only features were tried in this lesson under dash, the shell behind sh on Ubuntu: five stopped with an error and seven carried on with exit status 0 and the wrong result.

/bin/sh means POSIX sh, the shell language the standard defines, and Bash is POSIX sh plus many extras that other shells do not have. You spot those extras by running the script under dash and reading it with shellcheck -s sh, and you fix each one by writing its POSIX form. You will see which shell a script really gets, try the twelve extras that break, meet three behaviours that differ without any error, rewrite a script and check it, and pick the first line that fits.

Which shell a script gets, and what POSIX sh promises

Two machines can read the same #!/bin/sh script with two different programs. The shebang: The first line of a script, #! followed by the interpreter to run it, such as #!/usr/bin/env bash. The kernel reads it when the file is run by its path; to Bash it is a comment. More in “Your first script: shebang, chmod and running it” on the first line names the interpreter, and sh is only a name that each system points at a shell of its choice. Step through what happens between the first line and the shell that reads it:

Interactive · step by step Which shell runs a script that asks for sh Step through what happens between the first line of a script and the shell that reads it.
Which shell runs a script that asks for sh, step 4 of 4. Showing Script: #!/bin/sh, The system's sh, dash (POSIX, few extras), Bash run as sh (POSIX mode on). In focus: Bash run as sh.kernel startsUbuntuMac sessionScript: #!/bin/shThe system's shdashPOSIX, few extrasBash run as shPOSIX mode on

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. The first line decides. When you run ./job.sh, the kernel reads the first line and starts the program it names with the script as its argument. #!/bin/sh asks for whatever the system has installed under the name sh.
  2. sh is a name, not one shell. The system decides what sh is. Nothing in the script says which shell that will be, so the script may only use what every candidate has: the POSIX shell language.
  3. On Ubuntu it is dash. The Ubuntu dash package lists /usr/bin/sh among its files, and Debian policy lets a /bin/sh script assume only POSIX.1-2017 plus a few listed extras. dash adds little else, so a Bashism fails there.
  4. On the Mac session it is Bash. In the recorded session #!/bin/sh started Bash 3.2.57 with POSIXLY_CORRECT=y. Bash started as sh switches to POSIX mode but keeps its extras, so a Bashism can work on a Mac and fail on Ubuntu.

A POSIX shell: A shell that implements the shell command language the POSIX standard defines for the sh utility. Bash and dash both do; Bash adds many extras the standard does not require. is any shell that implements the language the standard defines. Bash and dash: The Debian Almquist shell, a small POSIX sh implementation with few extras. On Ubuntu its package provides /usr/bin/sh, so it is the usual strict test of a script that claims to be portable. both do, and only Bash adds a long list of extras. Debian policy lets authors of /bin/sh scripts assume POSIX.1-2017 plus a short list, and nothing more: echo -n printing no newline, -a and -o in test, local, and numeric signals for kill and trap. A bashism: A feature of Bash that POSIX does not require, such as arrays, [[ ]], {1..3} or the function keyword. A script that uses one can fail under dash or any other plain POSIX sh. is an extra outside that set. The next session starts one script body three ways and prints which shell ran it:

Which shell each first line started Python · shebang_choice_session.py
from session import run, write

# One script body, three first lines. The kernel starts the interpreter the first line names, so BASH_VERSION and
# POSIXLY_CORRECT show which shell really ran it. Recorded on a Mac, where /bin/sh is Apple's Bash 3.2.57 started as sh;
# on Debian or Ubuntu the first line would name dash and the script would print "not bash".
BODY = """echo "BASH_VERSION=${BASH_VERSION:-not bash}"
echo "POSIXLY_CORRECT=${POSIXLY_CORRECT:-unset}"
"""
write("by-sh.sh", "#!/bin/sh\n" + BODY)
write("by-env.sh", "#!/usr/bin/env bash\n" + BODY)
write("by-bin.sh", "#!/bin/bash\n" + BODY)
run("""
chmod +x by-sh.sh by-env.sh by-bin.sh
./by-sh.sh
./by-env.sh
./by-bin.sh
# the same file as by-sh.sh, started by dash: the first line is only a comment now
dash by-sh.sh
# the Mac's /bin/bash is too old for associative arrays
!/bin/bash -c 'declare -A seen'
bash -c 'declare -A seen=([a]=1); echo "${seen[a]}"'
""")

Output

$ chmod +x by-sh.sh by-env.sh by-bin.sh
$ ./by-sh.sh
BASH_VERSION=3.2.57(1)-release
POSIXLY_CORRECT=y
$ ./by-env.sh
BASH_VERSION=5.3.20(1)-release
POSIXLY_CORRECT=unset
$ ./by-bin.sh
BASH_VERSION=3.2.57(1)-release
POSIXLY_CORRECT=unset
# the same file as by-sh.sh, started by dash: the first line is only a comment now
$ dash by-sh.sh
BASH_VERSION=not bash
POSIXLY_CORRECT=unset
# the Mac's /bin/bash is too old for associative arrays
$ /bin/bash -c 'declare -A seen'
/bin/bash: line 0: declare: -A: invalid option
declare: usage: declare [-afFirtx] [-p] [name[=value] ...]
[exit status 2]
$ bash -c 'declare -A seen=([a]=1); echo "${seen[a]}"'
1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shebang_choice_session.py

The #!/bin/sh file printed BASH_VERSION=3.2.57(1)-release and POSIXLY_CORRECT=y. On this Mac sh is a Bash started under the name sh, and Bash then enters POSIX mode, though it keeps its extras. The #!/usr/bin/env bash file found Bash 5.3.20, the first one on PATH. The #!/bin/bash file ran the old 3.2.57 again, this time with POSIXLY_CORRECT unset. Then dash by-sh.sh started the same file: the shell named on the command line wins, the first line is only a comment to dash, and BASH_VERSION was empty, so the script printed not bash. That answer is what #!/bin/sh gives on Ubuntu, where the dash package provides /usr/bin/sh. The last two commands show why the version matters: the Mac’s /bin/bash rejected declare -A for an associative array with invalid option and status 2, and Bash 5.3.20 accepted it.

Testing a Bashism on a Mac

Wrong: a script with #!/bin/sh and a [[ ]] test, passed because it ran on a Mac, where sh is Bash. Right: run it with dash script.sh, because dash is the strict reader that Ubuntu uses.

What the Mac accepted says nothing about Ubuntu, so the sections that follow treat dash as the reference reader.

Key idea

sh names a role, not one program, so a script that asks for it may use only POSIX sh and the few extras Debian policy lists.

Checkpoint

Check: What sh means and who runs it

  1. Question 1 of 2 A script starts with #!/bin/sh. What does that line really promise?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Only that some POSIX sh will read it; which shell that is depends on the system

    The session started one body three ways: sh gave Bash 3.2.57 in POSIX mode on the Mac, and dash by-sh.sh gave "not bash".

  2. Question 2 of 2 A script with #!/bin/sh and a [[ ]] test works on your Mac and fails on Ubuntu. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: On the Mac sh is a Bash, on Ubuntu it is dash, which has no [[ ]]

    The Mac's sh printed POSIXLY_CORRECT=y with Bash extras intact; dash printed "[[: not found".

Twelve Bash features that dash does not have

Each of twelve one-line scripts uses one Bash feature. A small runner starts every file with Bash 5.3.20 and then with dash 0.5.13.5 and prints both outputs and both exit statuses, so you can read them side by side. The $RANDOM line prints set when the variable has a value, so the record does not change from run to run:

Twelve bashisms under Bash and under dash Python · bashism_zoo_session.py
from session import run, write

# Twelve one-line Bash-only features, each in its own file, run under GNU Bash 5.3.20 and under dash 0.5.13.5 by one
# small runner that prints both outputs and both exit statuses. The $RANDOM line prints "set" when $RANDOM has a value,
# so the recorded output does not change from run to run.
write("b01.sh", 'a=(one two three); echo "${a[1]}"\n')
write("b02.sh", 'x=abc; if [[ $x == a* ]]; then echo match; else echo no match; fi\n')
write("b03.sh", "echo {1..3}\n")
write("b04.sh", "function bye { echo bye; }; bye\n")
write("b05.sh", 'echo "x=1" > lib.sh; source ./lib.sh; echo "x=[$x]"\n')
write("b06.sh", 's=abcdef; echo "${s:1:3}"\n')
write("b07.sh", 'cat <<< "hello"\n')
write("b08.sh", 'echo "RANDOM is [${RANDOM:+set}]"\n')
write("b09.sh", 'printf "%q\\n" "a b"\n')
write("b10.sh", 'printf "a:b:c" | { read -r -d : first; echo "[$first]"; }\n')
write("b11.sh", 'x=a; if [ "$x" == a ]; then echo equal; else echo different; fi\n')
write("b12.sh", 'echo hi &> out.txt; wait; echo "out.txt holds: [$(cat out.txt)]"\n')
write("both.sh", r"""for f in "$@"; do
  printf '=== %s\n' "$(cat "$f")"
  for sh in bash dash; do
    out=$($sh "$f" 2>&1); rc=$?
    printf '%s (status %s):\n' "$sh" "$rc"
    printf '%s\n' "$out" | sed 's/^/  /'
  done
done
""")
run("""
echo "bash $BASH_VERSION"
bash both.sh b01.sh b02.sh b03.sh b04.sh b05.sh b06.sh
bash both.sh b07.sh b08.sh b09.sh b10.sh b11.sh b12.sh
""")

Output

$ echo "bash $BASH_VERSION"
bash 5.3.20(1)-release
$ bash both.sh b01.sh b02.sh b03.sh b04.sh b05.sh b06.sh
=== a=(one two three); echo "${a[1]}"
bash (status 0):
  two
dash (status 2):
  b01.sh: 1: Syntax error: "(" unexpected
=== x=abc; if [[ $x == a* ]]; then echo match; else echo no match; fi
bash (status 0):
  match
dash (status 0):
  b02.sh: 1: [[: not found
  no match
=== echo {1..3}
bash (status 0):
  1 2 3
dash (status 0):
  {1..3}
=== function bye { echo bye; }; bye
bash (status 0):
  bye
dash (status 2):
  b04.sh: 1: Syntax error: "}" unexpected
=== echo "x=1" > lib.sh; source ./lib.sh; echo "x=[$x]"
bash (status 0):
  x=[1]
dash (status 0):
  b05.sh: 1: source: not found
  x=[]
=== s=abcdef; echo "${s:1:3}"
bash (status 0):
  bcd
dash (status 2):
  b06.sh: 1: Bad substitution
$ bash both.sh b07.sh b08.sh b09.sh b10.sh b11.sh b12.sh
=== cat <<< "hello"
bash (status 0):
  hello
dash (status 2):
  b07.sh: 1: Syntax error: redirection unexpected
=== echo "RANDOM is [${RANDOM:+set}]"
bash (status 0):
  RANDOM is [set]
dash (status 0):
  RANDOM is []
=== printf "%q\n" "a b"
bash (status 0):
  a\ b
dash (status 2):
  b09.sh: 1: printf: %q: invalid directive
=== printf "a:b:c" | { read -r -d : first; echo "[$first]"; }
bash (status 0):
  [a]
dash (status 0):
  b10.sh: 1: read: Illegal option -d
  []
=== x=a; if [ "$x" == a ]; then echo equal; else echo different; fi
bash (status 0):
  equal
dash (status 0):
  b11.sh: 1: [: a: unexpected operator
  different
=== echo hi &> out.txt; wait; echo "out.txt holds: [$(cat out.txt)]"
bash (status 0):
  out.txt holds: [hi]
dash (status 0):
  hi
  out.txt holds: []

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bashism_zoo_session.py

Bash printed the expected result for all twelve. dash did not do the same for any of them, and it failed in two different ways. Five stopped with status 2: the array assignment (Syntax error: "(" unexpected), the function keyword, ${s:1:3} (Bad substitution), the here-string <<< (redirection unexpected) and printf %q (invalid directive). The other seven finished with status 0 and a wrong result. [[ was reported as not found, so the if took its else branch. {1..3} stayed the literal text {1..3}. source was not found and left x empty. $RANDOM expanded to nothing. read -d printed Illegal option -d and left the variable empty. == inside [ ] was unexpected operator, which made the test false. And &> was read as echo hi & followed by > out.txt, so hi went to the terminal and the file stayed empty.

Eleven of them have a POSIX form, and each form ran correctly in dash when tried on its own; printf %q has none:

Bash-only form What dash did POSIX sh form
a=(one two) syntax error set -- one two, then $2
[[ $x == a* ]] else branch case $x in a*)
echo {1..3} printed {1..3} for n in 1 2 3
function bye { …; } syntax error bye() { …; }
source ./lib.sh not found . ./lib.sh
${s:1:3} bad substitution printf into cut -c2-4
cat <<< "hello" syntax error a here-document
$RANDOM empty awk with srand() and rand()
printf '%q' invalid directive none: avoid needing it
read -r -d : illegal option IFS=: read -r a b
[ "$x" == a ] unexpected operator [ "$x" = a ]
cmd &> out.txt file left empty cmd > out.txt 2>&1

Newer POSIX editions add some features of this list: the read page of POSIX.1-2024 defines -d. dash 0.5.13.5 still printed Illegal option -d, so a feature in the standard is not yet a feature in every shell.

Reading only the exit status

Wrong: a test run in dash that checks $? and calls the script fine because it was 0. Right: read the output as well. Seven of the twelve ended with status 0 and still took the wrong path.

Each of the seven shows itself only in what was printed or written, which is why the checks later in this lesson read the output.

Key idea

In dash a bashism either stops the script with an error or quietly takes another path, so read the output and not only the exit status.

Checkpoint

Check: The twelve bashisms and their fixes

  1. Question 1 of 3 In dash, x=abc; if [[ $x == a* ]]; then echo match; else echo no match; fi ended with status 0. What did it print?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: [[: not found, then no match

    The session of twelve bashisms recorded "b02.sh: 1: [[: not found" and "no match" with status 0.

  2. Question 2 of 3 Which POSIX sh command replaces source ./lib.sh?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: . ./lib.sh

    dash printed "source: not found" and left x empty; with . ./settings.sh in the rewrite both shells printed the same lines.

  3. Question 3 of 3 In dash, echo hi &> out.txt ran. What does out.txt hold?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: Nothing, it is empty

    The session of twelve bashisms recorded hi on the terminal and "out.txt holds: []" in dash, against "[hi]" in Bash.

Three places where dash and Bash differ without an error

Some differences have no bashism in them. The code is valid POSIX sh, and the shells simply behave differently, because the standard leaves the behaviour open or Bash chose a different default. The next session runs three small files under Bash 5.3.20, under bash --posix and under dash 0.5.13.5:

echo, set -e and arithmetic in three shells Python · dash_differences_session.py
from session import run, write

# Three things dash does differently when a script uses only POSIX syntax: echo reads backslash escapes, set -e is
# passed into $( ), and $(( )) does not follow a variable whose value is another variable's name. A small runner prints
# GNU Bash 5.3.20, bash --posix and dash 0.5.13.5 for each file.
write("q1.sh", "echo 'one\\ntwo'\nprintf '%s\\n' 'one\\ntwo'\n")
write("q2.sh", 'set -e\nx=$(false; echo after)\necho "x is [$x]"\necho "script went on"\n')
write("q3.sh", "n=count\ncount=7\necho $(( n + 1 ))\n")
write("three.sh", r"""for f in "$@"; do
  printf '=== %s\n' "$f"
  for sh in "bash" "bash --posix" "dash"; do
    out=$($sh "$f" 2>&1); rc=$?
    printf '%s (status %s):\n' "$sh" "$rc"
    if [ -n "$out" ]; then printf '%s\n' "$out" | sed 's/^/  /'; else echo '  (no output)'; fi
  done
done
""")
run("""
cat q1.sh
bash three.sh q1.sh
cat q2.sh
bash three.sh q2.sh
cat q3.sh
bash three.sh q3.sh
""")

Output

$ cat q1.sh
echo 'one\ntwo'
printf '%s\n' 'one\ntwo'
$ bash three.sh q1.sh
=== q1.sh
bash (status 0):
  one\ntwo
  one\ntwo
bash --posix (status 0):
  one\ntwo
  one\ntwo
dash (status 0):
  one
  two
  one\ntwo
$ cat q2.sh
set -e
x=$(false; echo after)
echo "x is [$x]"
echo "script went on"
$ bash three.sh q2.sh
=== q2.sh
bash (status 0):
  x is [after]
  script went on
bash --posix (status 1):
  (no output)
dash (status 1):
  (no output)
$ cat q3.sh
n=count
count=7
echo $(( n + 1 ))
$ bash three.sh q3.sh
=== q3.sh
bash (status 0):
  8
bash --posix (status 0):
  8
dash (status 2):
  q3.sh: 3: Illegal number: count

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dash_differences_session.py

First, echo. POSIX leaves the result implementation-defined when an operand contains a backslash. Bash printed one\ntwo on one line with echo and with printf '%s\n'. dash read the escape in echo and printed two lines, and printf kept it as written. Bash needs its xpg_echo option to read escapes the same way. Print with printf '%s\n', as the printf and echo lesson shows.

Second, set -e in a command substitution. With x=$(false; echo after) Bash by default turns -e off inside the substitution, so echo after still runs, x becomes after and the script goes on. dash keeps -e on, the substitution stops at false, and the script ended with status 1 and no output. POSIX describes the dash behaviour. Bash gets it with bash --posix too, which switches on the inherit_errexit: A Bash shopt option, added in 4.4: when switched on, set -e is passed down into every command substitution $(...). Without it, Bash runs each $(...) in a subshell without errexit, so x=$(false; echo after) sets x=after and the script runs on; dash inherits errexit into command substitutions by default. More in “set -e, set -u and pipefail: what they really do” option, and the second run ended with status 1. Step through that one assignment to see the point where the two shells part:

Interactive · step by step What set -e does inside a command substitution Step through one assignment, x=$(false; echo after), and see where Bash and dash part ways.
What set -e does inside a command substitution, step 4 of 4. Showing Script, set -e on, Subshell of $( ), Bash default (x is [after]), dash, bash --posix (status 1, no output). In focus: Subshell of $( ), dash, bash --posix.startserrexit offerrexit onScript, set -e onSubshell of $( )Bash defaultx is [after]dash, bash --posixstatus 1, no output

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. The assignment. With set -e on, the script runs x=$(false; echo after). Bash starts a subshell to run the two commands inside the parentheses.
  2. Bash clears -e there. By default Bash switches -e off inside the subshell, so false only returns 1, echo after still runs, and x becomes after. The script printed both of its lines, x is [after] and then script went on.
  3. dash keeps -e on. dash passes -e into the subshell, so false ends it at once. The subshell returns 1, the assignment takes that status, and set -e stops the script with status 1 and no output at all.
  4. POSIX describes dash. POSIX.1-2024 says the false command makes the subshell exit without running the next command. Bash gets the same result with bash --posix or shopt -s inherit_errexit, and the recorded run of bash --posix ended with status 1.

Third, arithmetic. With n=count and count=7, Bash evaluated $(( n + 1 )) as 8 by looking through n to count, in default and in POSIX mode. dash answered Illegal number: count. POSIX only asks for variables whose value is a valid integer, and an expansion fails when the shell does not recognise a variable’s contents, so the Bash result is an extension.

bash --posix is not dash

Only one of the three results changed under bash --posix. The manual says the option is meant to make Bash “behave as a strict superset of that standard”, so extras such as arrays and $(( n + 1 )) stay. Passing there is not passing in dash.

Checkpoint

Check: Where dash differs on valid code

  1. Question 1 of 2 What does echo 'one\ntwo' print in dash 0.5.13.5?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: two lines, one and two

    The session printed "one" and "two" on separate lines in dash and one\ntwo in Bash; printf kept it as written in both.

  2. Question 2 of 2 A script runs set -e and then x=$(false; echo after) in dash. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The script stops with status 1 and prints nothing

    The record shows dash ending with status 1 and no output, and Bash printing x is [after].

A rewrite is done when ShellCheck is quiet and both shells agree

The best check is the strictest reader. The session below holds a short Bash script with a brace range, a [[ ]] test and source, and its rewrite in POSIX sh. ShellCheck reads each with -s sh, Bash and dash run them, and cmp compares the two outputs byte for byte. The end of the session tries two things that are easy to get wrong:

A Bash script rewritten as POSIX sh and checked Python · port_to_sh_session.py
from session import run, write

# A short Bash script (a brace list, a [[ ]] test, source) and its POSIX sh rewrite. ShellCheck reads both with
# -s sh, then Bash 5.3.20 and dash 0.5.13.5 run each one. The last part shows two things that are easy to get wrong:
# bash --posix still accepts the Bash script, and ShellCheck 0.11.0 still flags $'...' in sh.
write("settings.sh", "MODE=fast\n")
write("jobs.bash", """#!/bin/bash
source ./settings.sh
for n in {1..3}; do
  if [[ $MODE == fast && $n == 2 ]]; then
    echo "skip $n"
    continue
  fi
  echo "run $n in $MODE mode"
done
""")
write("jobs.sh", """#!/bin/sh
. ./settings.sh
for n in 1 2 3; do
  if [ "$MODE" = fast ] && [ "$n" = 2 ]; then
    echo "skip $n"
    continue
  fi
  echo "run $n in $MODE mode"
done
""")
write("tab.sh", """#!/bin/sh
tab=$'\\t'
printf '[%s]\\n' "$tab"
""")
run("""
# the Bash script first: ShellCheck in sh mode flags source, the brace list and [[ ]]
!shellcheck -s sh -x jobs.bash
bash jobs.bash
dash jobs.bash
# the rewrite: clean in sh mode, and both shells print the same lines
shellcheck -s sh -x jobs.sh && echo "shellcheck: no findings"
bash jobs.sh > bash.out
dash jobs.sh > dash.out
cat dash.out
cmp bash.out dash.out && echo "bash and dash printed the same bytes"
# bash --posix is not dash: it still runs the Bash script
bash --posix jobs.bash | head -n 2
# POSIX.1-2024 allows $'...': dash runs it, ShellCheck 0.11.0 still flags it in sh
dash tab.sh
!shellcheck -s sh tab.sh
""")

Output

# the Bash script first: ShellCheck in sh mode flags source, the brace list and [[ ]]
$ shellcheck -s sh -x jobs.bash

In jobs.bash line 2:
source ./settings.sh
^------------------^ SC3046 (warning): In POSIX sh, 'source' in place of '.' is undefined.
^------------------^ SC3051 (warning): In POSIX sh, 'source' in place of '.' is undefined.


In jobs.bash line 3:
for n in {1..3}; do
         ^----^ SC3009 (warning): In POSIX sh, brace expansion is undefined.


In jobs.bash line 4:
  if [[ $MODE == fast && $n == 2 ]]; then
     ^----------------------------^ SC3010 (warning): In POSIX sh, [[ ]] is undefined.
              ^-- SC3014 (warning): In POSIX sh, == in place of = is undefined.
                            ^-- SC3014 (warning): In POSIX sh, == in place of = is undefined.

For more information:
  https://www.shellcheck.net/wiki/SC3009 -- In POSIX sh, brace expansion is u...
  https://www.shellcheck.net/wiki/SC3010 -- In POSIX sh, [[ ]] is undefined.
  https://www.shellcheck.net/wiki/SC3014 -- In POSIX sh, == in place of = is ...
[exit status 1]
$ bash jobs.bash
run 1 in fast mode
skip 2
run 3 in fast mode
$ dash jobs.bash
jobs.bash: 2: source: not found
jobs.bash: 4: [[: not found
run {1..3} in  mode
# the rewrite: clean in sh mode, and both shells print the same lines
$ shellcheck -s sh -x jobs.sh && echo "shellcheck: no findings"
shellcheck: no findings
$ bash jobs.sh > bash.out
$ dash jobs.sh > dash.out
$ cat dash.out
run 1 in fast mode
skip 2
run 3 in fast mode
$ cmp bash.out dash.out && echo "bash and dash printed the same bytes"
bash and dash printed the same bytes
# bash --posix is not dash: it still runs the Bash script
$ bash --posix jobs.bash | head -n 2
run 1 in fast mode
skip 2
# POSIX.1-2024 allows $'...': dash runs it, ShellCheck 0.11.0 still flags it in sh
$ dash tab.sh
[	]
$ shellcheck -s sh tab.sh

In tab.sh line 2:
tab=$'\t'
    ^---^ SC3003 (warning): In POSIX sh, $'..' is undefined.

For more information:
  https://www.shellcheck.net/wiki/SC3003 -- In POSIX sh, $'..' is undefined.
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 port_to_sh_session.py

ShellCheck read jobs.bash as sh and named source (SC3046 and SC3051), the brace list (SC3009), [[ ]] (SC3010) and == (SC3014). Run in dash, the same file printed source: not found and [[: not found, looped once over the text {1..3}, and exited with status 0. The rewrite changed three things: a plain . ./settings.sh, a list 1 2 3 and two [ ] tests joined by &&. ShellCheck reported no findings, and the output of Bash and dash was the same bytes.

Here is the order of the checks and what each one can and cannot see:

Interactive · stage by stage Four checks for a script that says #!/bin/sh Step through the checks and note what each one finds, and what it leaves for the next.
Four checks for a script that says #!/bin/sh, step 4 of 4. Showing shellcheck -s sh, dash script.sh, checkbashisms, bash --posix. In focus: bash --posix.shellcheck -s shreads, never runsdash script.shruns it for realcheckbashismsDebian pattern listbash --posixnot a bashism check

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. Read it with ShellCheck. shellcheck -s sh jobs.bash named source, the brace list and [[ ]] with their SC30xx codes, before anything ran. It checks the text, so it cannot see what happens at run time.
  2. Run it with dash. dash jobs.bash printed "source: not found" and "[[: not found" and finished with status 0. Only running with a strict shell shows a wrong branch, so look at the output as well as the status.
  3. Add the Debian checker. checkbashisms from the devscripts package scans a script for features POSIX does not require. It is a second reader with its own rule list, useful next to ShellCheck. It was not run in this lesson.
  4. Do not trust bash --posix. bash --posix jobs.bash ran the Bash script without complaint. Posix mode changes some behaviour but keeps every Bash extra, so a script that passes there can still fail in dash.

Debian policy names checkbashisms: A Debian devscripts tool that scans a script meant for /bin/sh and warns about features POSIX does not require. Debian policy names it as a way to find violations. for this job. Its manual page says it is based on a check of Debian’s lintian tool, so it is a second reader to run next to ShellCheck. It comes with the Debian devscripts package, so it was not part of the recorded sessions.

bash --posix jobs.bash ran the Bash script and printed its first lines, which is why it is not a check for bashisms. The last two commands show a case where the standard has moved ahead of the tools. POSIX.1-2024 allows $'\t', and dash ran it, but ShellCheck 0.11.0 still reported SC3003 on it, and its page says the rule applied to POSIX.1-2017. Keep the warning if the script must also run under older shells. Add a # shellcheck disable=SC3003 comment above that one line when every shell you target has the feature.

Checkpoint

Check: Rewriting and checking for sh

  1. Question 1 of 2 A script runs without complaint under bash --posix. What does that show about dash?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Nothing certain, because posix mode keeps Bash extras such as arrays and [[ ]]

    In the session bash --posix ran jobs.bash, which dash had run with source and [[ errors.

  2. Question 2 of 2 ShellCheck -s sh reports SC3003 on tab=$'\t' and dash prints the tab. How do you read that?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: POSIX.1-2024 allows it and dash runs it, but the tool still follows POSIX.1-2017

    The session printed [tab] from dash and SC3003 from ShellCheck 0.11.0 for the same file.

The first line should say what the script needs

The first line states what the script needs. Answer the three questions below about a script of your own and see which first lines would run it wrongly; each one that is ruled out says why:

Interactive · answer and compare Which first line fits this script? Answer three questions about your script; the first lines that would run it wrongly are ruled out, with the reason.
Does the script use arrays, [[ ]], brace ranges or another Bashism?
Does it need a Bash newer than the 3.2.57 that a Mac still has at /bin/bash?
Will it also run on machines you do not set up, such as a minimal container?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three first lines against three things a script may need
First line Bashisms allowed One shell on every machine Bash newer than 3.2
#!/bin/sh The shell it asks for: the system's sh Poor dash stopped with an error or took the wrong path Mixed dash on Ubuntu, Bash 3.2.57 in the Mac session Poor no Bash-only features at all
#!/usr/bin/env bash The shell it asks for: the first bash on PATH Good any Bash feature, once Bash is found Mixed finds Bash 5.3.20 in the session, whichever is first Good picks up a newer Bash put first on PATH
#!/bin/bash The shell it asks for: the bash at one fixed path Good any Bash feature the installed version has Mixed always /bin/bash, but not always the same version Mixed whatever the system put there: 3.2.57 in the Mac session
How the answers decide
  • “Does the script use arrays, [[ ]], brace ranges or another Bashism?” Yes, it uses a Bashism: rules out #!/bin/sh. dash stopped with an error or took the wrong path for all twelve Bashisms, so a script that uses one needs a Bash first line.
  • “Does it need a Bash newer than the 3.2.57 that a Mac still has at /bin/bash?” Yes, newer features: rules out #!/bin/sh and adds a caveat to #!/bin/bash. A newer Bash rules out sh, which may be dash. /bin/bash is one fixed file, Bash 3.2.57 in the Mac session, while env found 5.3.20 first on PATH.
  • “Does the script use arrays, [[ ]], brace ranges or another Bashism?” No, it passes the checks and “Will it also run on machines you do not set up, such as a minimal container?” Yes, on unknown machines: adds a caveat to #!/usr/bin/env bash and #!/bin/bash. A script that passes the checks needs no Bash feature, so naming Bash only adds a dependency that sh would not have.
  • “Will it also run on machines you do not set up, such as a minimal container?” Yes, on unknown machines and “Does the script use arrays, [[ ]], brace ranges or another Bashism?” Yes, it uses a Bashism: adds a caveat to #!/usr/bin/env bash and #!/bin/bash. On a machine you did not set up, Bash may not be installed at all, so check for it or say in the instructions that the script needs it.

In words, the rule has three steps. If the script must run on machines you do not control and it passes the checks of the last section, keep it POSIX and start it with #!/bin/sh. If it needs a Bashism, a newer Bash, or just the comfort of arrays and [[ ]], say so in the first line instead of hoping that sh is Bash. Choose #!/usr/bin/env bash to run the first Bash on PATH, which found 5.3.20 in the session, or #!/bin/bash when you want one fixed path, which started 3.2.57, too old for the associative array of the first session. The .sh ending of a file name chooses nothing, and a script started as dash file ignores its first line.

Mark the dependency where it is used

Writing #!/usr/bin/env bash is not a failure of portability. It is an honest statement that the script needs Bash, and it lets a reader and a tool such as ShellCheck read the file in the right dialect.

Checkpoint

Check: Choosing the first line

  1. One question A script uses an array and [[ ]]. Which first line is wrong for it?

    Choose one answer.

    Show the answer: One question

    Answer: #!/bin/sh

    In the session of twelve bashisms dash gave a syntax error for the array and a wrong branch for [[ ]], so the script must name Bash.

Practise: bashisms and first lines

Each question gives a short script, a shell or a message, like the ones recorded above. Decide what dash or Bash does, then check the answer against the recorded output:

Practice that adapts to you

Practise this lesson

19 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 19 Which Ubuntu package lists /usr/bin/sh among its files?

    Choose one answer.

    Show the answer: Question 1 of 19

    Answer: dash

    The package page lists /usr/bin/dash and /usr/bin/sh.

  2. Question 2 of 19 In the session, #!/bin/sh printed POSIXLY_CORRECT=y and a Bash version. What does that mean?

    Choose one answer.

    Show the answer: Question 2 of 19

    Answer: Bash was started under the name sh and entered POSIX mode

    The record shows BASH_VERSION=3.2.57(1)-release with POSIXLY_CORRECT=y for the sh line.

  3. Question 3 of 19 A file starts with #!/bin/bash and you run dash file. Which shell reads it?

    Choose one answer.

    Show the answer: Question 3 of 19

    Answer: dash, and the first line is just a comment

    dash by-sh.sh printed "not bash" although the file's first line named sh, and a first line naming Bash would be a comment in the same way.

  4. Question 4 of 19 Of the twelve one-line bashisms, how many ended with exit status 0 in dash although the result was wrong?

    Type a number.

    Show the answer: Question 4 of 19

    Answer: 7 scripts

    Five stopped with status 2 (array, function, substring, here-string, printf %q); the other seven finished with status 0.

  5. Question 5 of 19 What does echo {1..3} print in dash?

    Choose one answer.

    Show the answer: Question 5 of 19

    Answer: {1..3}

    The session of twelve bashisms printed "1 2 3" in Bash and "{1..3}" in dash, both with status 0.

  6. Question 6 of 19 Which POSIX test replaces [ "$x" == a ]?

    Choose one answer.

    Show the answer: Question 6 of 19

    Answer: [ "$x" = a ]

    dash reported "[: a: unexpected operator" for ==, which made the test false.

  7. Question 7 of 19 The session of twelve bashisms printed RANDOM is [] under dash. Why?

    Choose one answer.

    Show the answer: Question 7 of 19

    Answer: dash has no $RANDOM, so it expands to nothing

    Bash printed RANDOM is [set]; dash printed RANDOM is [] with status 0.

  8. Question 8 of 19 POSIX.1-2024 defines read -d, yet dash 0.5.13.5 printed Illegal option -d. What does that teach?

    Choose one answer.

    Show the answer: Question 8 of 19

    Answer: A feature in the newest standard may be missing from the shell you run

    The session of twelve bashisms printed "read: Illegal option -d" and left the variable empty.

  9. Question 9 of 19 Which command prints a\nb exactly as written, in both Bash and dash?

    Choose one answer.

    Show the answer: Question 9 of 19

    Answer: printf '%s\n' 'a\nb'

    The session printed one\ntwo from printf in both shells.

  10. Question 10 of 19 n=count and count=7. What does echo $(( n + 1 )) print in dash?

    Choose one answer.

    Show the answer: Question 10 of 19

    Answer: An error: Illegal number: count

    dash ended with status 2 and "Illegal number: count", where Bash printed 8 in both default and posix mode.

  11. Question 11 of 19 Which choice makes Bash stop at x=$(false; echo after) under set -e, as dash does?

    Choose one answer.

    Show the answer: Question 11 of 19

    Answer: bash --posix, or shopt -s inherit_errexit

    The second run of three.sh, bash --posix, ended with status 1 and no output.

  12. Question 12 of 19 You must keep an old script that uses echo with \n escapes and run it under Bash. Which option changes Bash echo?

    Choose one answer.

    Show the answer: Question 12 of 19

    Answer: shopt -s xpg_echo

    A better fix is printf, which behaves the same everywhere; xpg_echo keeps old scripts running in Bash.

  13. Question 13 of 19 Which command checks jobs.sh against the rules of POSIX sh?

    Choose one answer.

    Show the answer: Question 13 of 19

    Answer: shellcheck -s sh jobs.sh

    ShellCheck named source, the brace list, [[ ]] and == in jobs.bash and nothing in jobs.sh.

  14. Question 14 of 19 Which loop head is valid POSIX sh and equals for n in {1..3}?

    Choose one answer.

    Show the answer: Question 14 of 19

    Answer: for n in 1 2 3; do

    The rewrite used for n in 1 2 3 and both shells printed the same bytes.

  15. Question 15 of 19 Which tool does Debian policy name for finding violations of the POSIX sh rules?

    Choose one answer.

    Show the answer: Question 15 of 19

    Answer: checkbashisms, from the devscripts package

    checkbashisms scans /bin/sh scripts for features POSIX does not require.

  16. Question 16 of 19 You rewrote a script for sh. Why compare its Bash output and its dash output with cmp?

    Choose one answer.

    Show the answer: Question 16 of 19

    Answer: A match shows the rewrite means the same in both shells, not only that it starts

    The record shows "bash and dash printed the same bytes" for jobs.sh.

  17. Question 17 of 19 In the session #!/usr/bin/env bash started Bash 5.3.20 and #!/bin/bash started 3.2.57. Why?

    Choose one answer.

    Show the answer: Question 17 of 19

    Answer: env runs the first bash on PATH, while /bin/bash is one fixed file

    env follows PATH, so putting a newer Bash first changes the result; a fixed path never does.

  18. Question 18 of 19 Does naming a file job.sh make it run under sh?

    Choose one answer.

    Show the answer: Question 18 of 19

    Answer: No, the first line or the command that starts it chooses the shell

    Both by-sh.sh and by-env.sh end in .sh and ran different shells.

  19. Question 19 of 19 An installer uses only POSIX features, passes shellcheck -s sh and runs in dash, and must run on machines you do not control. Which first line fits?

    Choose one answer.

    Show the answer: Question 19 of 19

    Answer: #!/bin/sh

    A script that passes the checks of the last section can ask for sh. For such a script the table of the first-line section gives both Bash lines a caveat, because they add a dependency the script does not need.

Interview questions

Warm-up (fresher to 2–5 years): what makes a feature a bashism, and how would you find one before it reaches a server?

A bashism is a feature of Bash that the POSIX shell language does not require, such as arrays, [[ ]], {1..3}, source, ${s:1:3} and <<<. It matters only when the script claims to be plain sh, because dash then rejects or mishandles it, sometimes with exit status 0. To find one, run shellcheck -s sh script.sh, which names the construct, and run the script under dash script.sh, reading the output and not only the status. checkbashisms from the Debian devscripts package is a second reader. If the script really needs a Bashism, name Bash on the first line instead.

Two harder questions take the same skills to cases this lesson does not show: a root check in a container entrypoint that dash lets through, and a first line with shell options that one system accepts and another refuses. Note the first command you would try on each before you read its suggested first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to staff

    A container entrypoint starts with #!/bin/sh and worked on a Mac. It must stop unless run as root, checked with [ "$EUID" -ne 0 ], and loads settings with conf=$(< app.conf). On an Ubuntu-based image, run as a non-root user, it logs one error line, starts with empty settings and exits 0. Explain both failures and fix them.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to staff

    A teammate's deploy script starts with #!/usr/bin/env bash -eu. It runs on their Mac with both options on, but on the Ubuntu runner it stops before its first command with "No such file or directory" and status 127. Explain the difference, pick a first line for both, and say what options there can never guarantee.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Which program runs a script whose first line is #!/bin/sh?

    Whatever shell the system puts at /bin/sh: dash on Ubuntu, Bash 3.2.57 started as sh on the Mac of the lesson's session. sh names a role, so such a script may rely only on POSIX sh.

  2. What is a bashism?

    A Bash feature that the POSIX shell language does not require, such as arrays, [[ ]], {1..3}, source, ${s:1:3} and <<<. It matters only in a script that claims to be plain sh.

  3. Why is a zero exit status not enough to show that a sh script ran correctly under dash?

    Of the twelve Bash features the lesson tried, five failed in dash with an error and status 2, and seven ended with status 0 and a wrong result, so the output must be checked too.

  4. What are the POSIX forms of [[ ]], source, &> and echo for data?

    case or [ ] instead of [[ ]], . instead of source, cmd > file 2>&1 instead of &>, and printf '%s\n' instead of echo for data.

  5. Where does dash behave differently from Bash on code that is valid in both?

    dash's echo reads backslash escapes, its set -e reaches into $( ), and its $(( )) does not look through a variable that holds another variable's name.

  6. How do you check that a script really is POSIX sh?

    Run shellcheck -s sh on it and run it with dash. bash --posix keeps every Bash extra, so a pass there proves nothing about dash.

Key takeaways

  • /bin/sh names a role that each system fills with its own shell: dash on Ubuntu, and Bash 3.2.57 started as sh in the Mac session, so a script that asks for sh may rely only on POSIX sh.
  • All twelve Bash features tried in this lesson failed under dash, five with an error and status 2 and seven with status 0 and a wrong result, so the output matters as much as the exit status.
  • Most bashisms have a POSIX form: case or [ ], a written-out list or a counter, . for source, cmd > file 2>&1 for &>, and printf '%s\n' instead of echo for data.
  • dash also differs on valid code: echo reads backslash escapes, set -e reaches into $( ), and $(( )) will not look through a variable that holds another name.
  • Check a script with shellcheck -s sh and with dash script.sh; bash --posix keeps every Bash extra, so it proves nothing about dash.
  • Use #!/bin/sh only for a script that passes those checks; otherwise name Bash on the first line and say that it is needed.

Terms in this lesson

bashism
A feature of Bash that POSIX does not require, such as arrays, [[ ]], {1..3} or the function keyword. A script that uses one can fail under dash or any other plain POSIX sh.
checkbashisms
A Debian devscripts tool that scans a script meant for /bin/sh and warns about features POSIX does not require. Debian policy names it as a way to find violations.
dash
The Debian Almquist shell, a small POSIX sh implementation with few extras. On Ubuntu its package provides /usr/bin/sh, so it is the usual strict test of a script that claims to be portable.
POSIX shell
A shell that implements the shell command language the POSIX standard defines for the sh utility. Bash and dash both do; Bash adds many extras the standard does not require.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.