Stop half-POSIXing your shell scripts

I have already argued in this general direction, but thought that I might as well put it more clearly and bluntly.

Most Bash scripts I see in the wild seem stuck in shell limbo, hesitating between POSIX-compliance and Bash fluency.

My proposal is simple:

  1. If you need your shell script to be POSIX-compliant, then write a POSIX-compliant shell script.
  2. If, however, you don’t need your script to be POSIX-compliant, then take full advantage of the scripting language you’re using.
  3. Do not write a half–POSIX-compliant script. Decide.

The worst of both worlds

When you write a half–POSIX-compliant script, you get the worst of both worlds.

You don’t increase portability.

  • Your script won’t run in machines where your shell scripting language is unavailable.
    • Suppose the machine has no Bash. If your script’s first line has bash, it’ll fail, because the bash executable won’t be found. Over there, your script is broken.
  • If your script’s first line has sh but the script has Bashisms (a bad combination), what happens will depend on the machine.
    • It’ll likely fail, because the sh executable is most often symlinked to a POSIX shell. A single non-compliance will be enough for it to fail.
    • It wouldn’t fail in operating systems that symlink sh to bash — but you don’t want to count on that, do you?

At the same time, your hesitation to fully embrace Bashisms means you miss out on much of the (non-POSIX) features that Bash can offer.

A genealogy of shells

How different from POSIX is the language? What features are unique to it?

In particular, how do Bash and Zsh — which, I believe, are by far the two most widely used non-fully-POSIX shell scripting languages — differ from POSIX?

A genealogy of shells can go a long way in the direction of answering these questions, and Emacs users need only open sh-script.el to find information about it. For example, the docstring of the variable sh-ancestor-alist shows us the following:

(defcustom sh-ancestor-alist
...
"Alist showing the direct ancestor of various shells.
This is the basis for `sh-feature'.  See also `sh-alias-alist'.
By default we have the following three hierarchies:

csh             C Shell
  jcsh            C Shell with Job Control
  tcsh            TENEX C Shell
    itcsh           Ian's TENEX C Shell
rc              Plan 9 Shell
  es              Extensible Shell
sh              Bourne Shell
  ash             Almquist Shell
    dash            Debian Almquist Shell
  jsh             Bourne Shell with Job Control
    bash            GNU Bourne Again Shell
    ksh88           Korn Shell '88
      ksh             Korn Shell '93
    dtksh           CDE Desktop Korn Shell
      pdksh           Public Domain Korn Shell
        mksh            MirOS BSD Korn Shell
      wksh            Window Korn Shell
      zsh             Z Shell
  oash            SCO OA (curses) Shell
  posix           IEEE 1003.2 Shell Standard
  wsh             ? Shell"
...)

Behold zsh, niece of bash, niece of posix, daughter of sh, the Bourne Shell.

Yet in spite of the biblical authority that seems to emanate from the previous sentence, it doesn’t tell us much about their differences. So let’s have a look at the sh-builtins variable, which I redact and reorder for clarity:

sh-builtins
=> '(;; some others here
     ;; ... jsh, tscsh, etc
     (shell "cd" "echo" "eval" "set" "shift" "umask" "unset" "wait")
     (bourne sh-append shell "eval" "export" "getopts" "newgrp" "pwd" "read"
             "readonly" "times" "ulimit")
     (sh sh-append bourne "hash" "test" "type")
     (posix sh-append sh "command")
     (bash sh-append posix "." "alias" "bg" "bind" "builtin" "caller" "compgen"
           "complete" "declare" "dirs" "disown" "enable" "fc" "fg" "help"
           "history" "jobs" "kill" "let" "local" "popd" "printf" "pushd" "shopt"
           "source" "suspend" "typeset" "unalias" "mapfile" "readarray" "coproc")
     ;; ...
     (ksh88 sh-append bourne "alias" "bg" "false" "fc" "fg" "jobs" "kill" "let"
            "print" "time" "typeset" "unalias" "whence")
     (zsh sh-append ksh88 "autoload" "always" "bindkey" "builtin" "chdir"
          "compctl" "declare" "dirs" "disable" "disown" "echotc" "enable"
          "functions" "getln" "hash" "history" "integer" "limit" "local" "log"
          "popd" "pushd" "r" "readonly" "rehash" "sched" "setopt" "source"
          "suspend" "true" "ttyctl" "type" "unfunction" "unhash" "unlimit"
          "unsetopt" "vared" "which"))

(I’ll focus on Bash, but the same logic applies to Zsh.)

So to get from nothing to Bash, we need to add builtin commands:

From To Add
shell cd echo eval set shift umask unset wait
shell bourne eval export getopts newgrp pwd read readonly times ulimit
bourne sh hash test type
sh posix command
posix bash . alias bg bind builtin caller compgen complete declare
    dirs disown enable fc fg help history jobs kill let local
    popd printf pushd shopt source suspend typeset unalias
    mapfile readarray coproc

(Note that only command is added from sh to posix. In fact, the two terms are often used interchangeably, because the sh executable is often a symlink to a POSIX-compatible descendent of the Bourne Shell, usually ash or dash.)

These last four rows show an important difference between POSIX and Bash: these builtin commands are only available in the latter. If you want POSIX, you can’t have them.

Now, some of these builtin commands are more commonly used only in your own environment.

  • Things like alias, unalias, and bind are more likely to be found in your .bash_aliases.
  • And history, disown, fc, fg, bg, jobs, suspend, kill, enable, popd, pushd, dirs, and help are more likely to be used interactively, in your Bash terminal.

But the other ones could easily find their way into a more general-purpose script.

What do you lose without these builtins?

You cannot:

  • use local to restrict a variable’s scope to the current function, which is bad news for functional programmers: Shell without local is like Lisp without (let).
  • use mapfile or readarray to read contents into an array.
  • use declare or typeset to set variable values and attributes (particularly relevant for array declaration and indirect references).
  • use source or . to source files, which is as close as you can get to libraries around there.
  • use caller to determine whether your script is being sourced, and from where — as used, e.g., at the very end of Ecos’ source.
  • use compgen and complete, without which you can no longer configure and offer command line completion.
  • use builtin printf’s additional flags — such as -v to store the result in a variable instead of printing.

That’s only a part of it. Much of the loss happens at the level of syntax.

Beyond builtins

Here’s a sample of Bash syntax features that you’d have to remove to make your script POSIX-compliant:

declare -A x=([NU]="Niue" [NZ]="New Zealand"); echo "${x[NZ]}"  # Arrays
sed "s/bar/foo/" <<< "A foo is a bar."                          # Here strings
echo {1..6}{1..6}                                               # Brace expansion
echo "${@:2} ${x,,} ${y//foo/bar} ${v:=def} ${name%.md}.html"   # Parameter expansion
diff <(sort version1) <(sort version2)                          # Process substitution
echo "$((x % 2))"                                               # Arithmetic expansion
((x++, y--, z=x*y))                                             # Arithmetic evaluation
[[ "$1" =~ ^([0-9]{4})-([0-9]{2})$ ]]; y="${BASH_REMATCH[1]}"   # Conditional [[ command
frobnicate-flanges-asynchronously() { frob-flanges "$@" &}      # Dash in function names
foo-hex() { : "${1::3}";: "0x${_,}"; echo "$_ = $((_))" ;}      # Ultimate Bash idioms

None of these are POSIX. How much do you care about using them?

Personally, I care a lot. I’ve written about here strings, brace expansion, parameter expansion, arithmetic expansion and evaluation, Ultimate Bash, and a few other underappreciated Bash idioms. I find that all of these make shell scripting much more pleasant.

And Lispers everywhere would (I hope) promptly join me in appreciating the aesthetic advantages of hyphenated function names.

You also want to think twice before dispensing with arrays.

Arrays support script safety

If you haven’t yet read How to do things safely in Bash, make it a priority. Your Bash will improve.

When explaining why focus on Bash instead of other shells, it dismisses POSIX right off the bat:

This guide is here to show that bash can be used safely.

Clearly, bash is a bad choice, but other prevalent alternatives are not better:

  • POSIX shell (a language subset that many shells support) lacks arrays. → Disqualified.
    • Hereunder: dash, busybox ash


Arrays is the feature that becomes absurdly impractical to program correctly without.

The recommendation of this guide must therefore be to not give POSIX compatibility a second thought. The POSIX shell standard is hereby declared unfit for our purposes. Likewise, sadly, for minimalistic POSIX compatible shells like Dash and Ash that don’t support arrays either. As for Zsh, it supports a superset of Bash’s array syntax, so it is good.

I can come up with counterexamples where a lack of arrays is not an issue, but this quickly gets harder in scripts beyond a certain (not very large) size or complexity.

So portability may come with a price whenever POSIX compatibility imposes this sort of trade-off. Your script may well reach more people — but less safely.

So when should a script be made POSIX-compliant?

I can make a case for POSIX-complying scripts in some simple, extreme scenarios. Move away from them and I find it harder to defend its need.

Script POSIXness seems to have more to recommend it when:

  • it’s a very simple script,
  • targeting a wide audience,
  • a great part of which may well be in older machines or simpler architectures.

Here’re two examples that fit the above.

Installations

You’re shipping an installation script for a program that targets all sorts of architectures and operating systems. If you can write one good, safe, short, POSIX-compliant script that can find out about its environment to install the program, then you might not need to write a second one.

Wrappers

POSIX also seems perfectly adequate for tiny wrappers such as this one:

#!/bin/sh
export PATH=/usr/bin
exec gzip -9 "$@"

to be saved as $HOME/bin/gzip and chmod +x’d. When invoking gzip, this would be called instead of the binary /usr/bin/gzip, to which it then immediately surrenders control with the -9 flag passed. This assumes that $HOME/bin is prepended to your PATH.

This script is suggested by gzip itself as an example to replace the no longer recommended environmental variable GZIP — that is, instead of setting GZIP="-9" in, say, your .bash_aliases, you use that script.

ShellChecking a particularly non-POSIX specimen

Behold this contrived example that artificially packs an assortment of non-POSIX material into a dozen or so lines:

#!/usr/bin/env bash

maybe-she-said()
{ local   foo bar zoo quux x face
  read -r foo bar zoo quux x face <<< "$@"
  [[ -n "$face" && "${bar,,}" =~ ^[a-z]+$ ]] && ((x>foo)) &&
      grep -q "${zoo:1}" < <(<<<"$quux" tr -d ,) && cat <<EOF
"I picked the number '$((RANDOM % 42))' randomly", she said,
and returned to her ${BASH_REMATCH[0]}.
EOF
  echo "🐱 = $_" ;}

maybe-she-said "$@"

Let’s save the above to the file ./mss and make it executable:

chmod +x ./mss

Now let’s run it:

./mss 2 HOME fit hi,there 3
🐱 = face

One more time:

./mss 2 HOME fit hi,there 3 whatnow?
"I picked the number '17' randomly", she said,
and returned to her home.
🐱 = cat

What does ShellCheck have to say about this script?

As Bash

Linting it as Bash, it outputs nothing: no error.

shellcheck -s bash -x mss

As POSIX sh

Linting it as POSIX sh, however...

shellcheck -s sh -x mss
In mss line 3:
maybe-she-said()
^-- SC3033 (warning): In POSIX sh, naming functions outside [a-zA-Z_][a-zA-Z0-9_]* is undefined.


In mss line 4:
{ local   foo bar zoo quux x face
  ^-----------------------------^ SC3043 (warning): In POSIX sh, 'local' is undefined.


In mss line 5:
  read -r foo bar zoo quux x face <<< "$@"
                                  ^-^ SC3011 (warning): In POSIX sh, here-strings are undefined.


In mss line 6:
  [[ -n "$face" && "${bar,,}" =~ ^[a-z]+$ ]] && ((x>foo)) &&
  ^-- SC3010 (warning): In POSIX sh, [[ ]] is undefined.
                    ^------^ SC3059 (warning): In POSIX sh, case modification is undefined.
                                                ^-------^ SC3006 (warning): In POSIX sh, standalone ((..)) is undefined.


In mss line 7:
      grep -q "${zoo:1}" < <(<<<"$quux" tr -d ,) && cat <<EOF
               ^------^ SC3057 (warning): In POSIX sh, string indexing is undefined.
                           ^-------------------^ SC3001 (warning): In POSIX sh, process substitution is undefined.
                             ^-^ SC3011 (warning): In POSIX sh, here-strings are undefined.


In mss line 8:
"I picked the number '$((RANDOM % 42))' randomly", she said,
                         ^----^ SC3028 (warning): In POSIX sh, RANDOM is undefined.


In mss line 9:
and returned to her ${BASH_REMATCH[0]}.
                    ^----------------^ SC3028 (warning): In POSIX sh, BASH_REMATCH is undefined.
                    ^----------------^ SC3054 (warning): In POSIX sh, array references are undefined.


In mss line 11:
  echo "🐱 = $_" ;}
            ^-- SC3028 (warning): In POSIX sh, _ is undefined.

This last warning implies: one cannot Ultimate Bash in POSIX.

Back to the main point

I have my preferences, which should be obvious; yours might be different.

But whatever your preferences might be, don’t half-POSIX your shell script. Either:

  1. fully POSIX-comply it (and put an sh in its first line), or
  2. fully embrace and use Bashisms — the whole shebang.
📆 2026-W16-3📆 2026-04-15