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:
- If you need your shell script to be POSIX-compliant, then write a POSIX-compliant shell script.
- If, however, you don’t need your script to be POSIX-compliant, then take full advantage of the scripting language you’re using.
- 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 thebashexecutable won’t be found. Over there, your script is broken.
- Suppose the machine has no Bash. If your script’s first line has
- If your script’s first line has
shbut the script has Bashisms (a bad combination), what happens will depend on the machine.
- It’ll likely fail, because the
shexecutable 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
shtobash— but you don’t want to count on that, do you?
- It’ll likely fail, because the
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, andbindare more likely to be found in your.bash_aliases. - And
history,disown,fc,fg,bg,jobs,suspend,kill,enable,popd,pushd,dirs, andhelpare 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
localto restrict a variable’s scope to the current function, which is bad news for functional programmers: Shell withoutlocal …is like Lisp without(let …). - use
mapfileorreadarrayto read contents into an array. - use
declareortypesetto set variable values and attributes (particularly relevant for array declaration and indirect references). - use
sourceor.to source files, which is as close as you can get to libraries around there. - use
callerto determine whether your script is being sourced, and from where — as used, e.g., at the very end of Ecos’ source. - use
compgenandcomplete, without which you can no longer configure and offer command line completion. - use builtin
printf’s additional flags — such as-vto 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 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:
- fully POSIX-comply it (and put an
shin its first line), or - fully embrace and use Bashisms — the whole shebang.
📆 2026-W16-3📆 2026-04-15