The whole course has taken one thing for granted: that the interpreter is Bash 5 on Ubuntu 24.04. It is a reasonable assumption for srv-veloz-01/02/03, and that is why we have used associative arrays, mapfile, [[ ]], ${s^^} and set -o pipefail without thinking twice. But the day someone has to write the entrypoint.sh for the veloz-api Alpine container, or a boot script that runs before Bash is available, or a tool a colleague will run on their macOS, that assumption breaks and the script fails with baffling errors. This lesson explains what POSIX is, which of our constructs are bashisms, what their equivalents are, why external tools cause more trouble than the shell itself, how to check portability and — most importantly — when it is worth it and when it is a burden.
Contents
- What POSIX is and what a bashism is
- The real-world picture: who
/bin/shis on each system - Table of bashisms and their POSIX equivalent
- Details you have to know when dropping down to POSIX
- External tools hurt more than the shell
- How to check portability
- The design decision, with an explicit criterion for Veloz Envíos
- Declaring the dependency: checking
BASH_VERSINFO - What each Bash version brings and the macOS problem
- Module 8 in review
- What POSIX is and what a bashism is
POSIX is a standard (IEEE 1003.1) that defines, among many other things, the shell language: which syntax any shell claiming conformance must understand. It is the lowest common denominator, and it is deliberately austere: it has no arrays, no [[ ]], no string substitution in expansions.
A bashism is any construct Bash understands and the standard does not require. They are not errors — they are the reasons Bash is convenient — but they only work where Bash is present. The problem arises from one specific confusion: writing #!/bin/sh on the first line and using Bash syntax inside. On Ubuntu, if you also test it by running bash script.sh, it works; in the Alpine container, where /bin/sh is a different program, it fails.
- The real-world picture: who
/bin/sh is on each system
/bin/sh is on each system| System | /bin/sh is |
Consequence |
|---|---|---|
| Debian / Ubuntu | dash |
Fast and strict: no bashism works |
| Alpine (containers) | ash (BusyBox) |
Even more reduced; and the tools are BusyBox too |
| RHEL / Fedora | bash in POSIX mode |
Many bashisms "work": a false sense of security |
| macOS | bash 3.2 or zsh |
Old Bash: no associative arrays and no mapfile |
| FreeBSD / Solaris | Their own sh, ksh |
Differences in the details |
The two dangerous rows are RHEL's and macOS', for opposite reasons. On RHEL your script with bashisms and #!/bin/sh works, so nobody detects the problem until it reaches Debian. On macOS the interpreter is called bash but it is version 3.2, from 2006 — Apple does not update it for licensing reasons — so a script that is perfectly correct for Bash 5 fails with cryptic messages.
The typical error looks like this:
[[: not found is the unmistakable signature of a bashism under dash: it is not a syntax error, it is that dash is looking for a command called [[.
- Table of bashisms and their POSIX equivalent
| Bashism | POSIX equivalent | Note |
|---|---|---|
[[ $a == $b ]] |
[ "$a" = "$b" ] |
In POSIX the quotes are mandatory |
[[ $s == a* ]] |
case $s in a*) ;; esac |
POSIX has no pattern comparison in [ ] |
[[ $s =~ regex ]] |
expr "$s" : 'regex' or grep -q |
No BASH_REMATCH; the most painful one |
arr=(a b c), ${arr[@]} |
Positional parameters: set -- a b c, "$@" |
A single "array" per scope |
declare -A |
Does not exist | No associative arrays: a temp file or awk |
${s//a/b} |
printf '%s' "$s" | sed 's/a/b/g' |
Costs a process |
${s^^} / ${s,,} |
tr '[:lower:]' '[:upper:]' |
Same |
${s:0:3} |
printf '%s' "$s" | cut -c1-3 |
${s#...} and ${s%...} are POSIX |
$(( a + b )) |
$(( a + b )) |
It is POSIX, use it without fear |
(( i++ )) |
i=$(( i + 1 )) |
The (( )) command is not POSIX |
local x |
(not in the standard) | dash, ash and ksh implement it: safe in practice |
function f { }, source f |
f() { }, . f |
The parenthesis form and the dot are the portable ones |
echo -e "a\tb" |
printf 'a\tb\n' |
echo with options is not portable |
<<< "$string" |
<<EOF … EOF or printf ... | |
Here-strings are a Bash feature |
<(cmd) |
A temp file with mktemp |
Process substitution: no equivalent |
arr+=(x), s+=text |
s="${s}text" |
The += operator is not POSIX |
${BASH_SOURCE[0]}, read -a, mapfile |
$0; while read + set -- |
Without arrays there is no direct equivalent |
set -o pipefail, trap ... ERR |
Do not exist | See section 4; only trap ... EXIT INT TERM |
{1..10} |
seq 1 10 or a while |
Brace expansion is not POSIX |
&> file |
> file 2>&1 |
Very easy to forget |
- Details you have to know when dropping down to POSIX
[ ] is not [[ ]]. It is a command, not syntax, so its arguments suffer word splitting. [ $a = $b ] with an empty $a becomes [ = value ] and gives a syntax error. With quotes — [ "$a" = "$b" ] — it always works. Use =, not ==, and -a/-o are obsolete: chain with && and || between two [ ].
No pipefail. This is the most serious loss, because cmd1 | cmd2 returns cmd2's code and a failure of the first goes unnoticed (05-03). The workable way out is to break the pipeline: write the first stage to a temp file with mktemp, check its exit code and read the temp file in the second stage. In practice, a POSIX script is restructured so as not to chain commands whose failure matters.
No arrays. The substitute is the positional parameters:
set -- Valencia Sevilla Bilbao Madrid # "array" of 4 elements
for city in "$@"; do printf 'processing %s\n' "$city"; done
printf 'total: %d\n' "$#"It is a single set per scope, so if you need two lists at once, you either keep one inside a function or move to awk.
No local, in theory. The standard does not include it, but dash, ash, ksh and zsh implement it. The practical recommendation: use it, and document in the header that the script requires a shell with local.
- External tools hurt more than the shell
This is the part almost everybody underestimates. You can write an impeccable POSIX script and have it fail on macOS because sed -i works differently. Linux uses GNU coreutils; macOS and the BSDs use the BSD tools; Alpine uses BusyBox, which is a third, reduced implementation.
| Use | GNU (Linux) | BSD / macOS | Portable |
|---|---|---|---|
| Edit in place | sed -i 's/a/b/' f |
sed -i '' 's/a/b/' f |
sed 's/a/b/' f > t && mv t f |
| Relative date | date -d '3 days ago' |
date -v-3d |
Compute with $(( )) over date +%s |
| Date from epoch | date -d "@$s" |
date -r "$s" |
None: detect the variant |
| Absolute path | readlink -f |
Does not exist (use realpath or stat) |
cd "$(dirname "$f")" && pwd |
| Perl regex | grep -P '\d+' |
Not supported | grep -E '[0-9]+' |
| File size | stat -c %s f |
stat -f %z f |
wc -c < f |
| With no input, do not run | xargs -r |
Default behavior | xargs -r only on GNU/BusyBox |
| Formatting in find | find . -printf '%s\n' |
Does not exist | find . -exec stat ... \; |
Three strategies, in order of preference:
- Stick to each tool's POSIX options.
sed 's/a/b/'without-i,grep -E,find -exec. It is the most robust and almost always enough. - Detect the variant at startup and adapt:
if date -d '@0' >/dev/null 2>&1
then date_from_epoch() { date -d "@$1" +%F; } # GNU
else date_from_epoch() { date -r "$1" +%F; } # BSD
fi- Declare the dependency: require
coreutilsand check it withveloz_require(06-03). On macOS they are installed withbrew install coreutils, though they end up with agprefix (gsed,gdate).
- How to check portability
Three complementary tools, plus a real run:
$ checkbashisms entrypoint.sh # devscripts package
possible bashism in entrypoint.sh line 14 (should be '.', not 'source'):
source /etc/veloz/env.sh
possible bashism in entrypoint.sh line 22 ([[ ... ]]):
if [[ -z "$VELOZ_API_URL" ]]; then
$ shellcheck -s sh entrypoint.sh # 08-05, POSIX dialect
In entrypoint.sh line 22:
^-- SC2039 (warning): In POSIX sh, [[ ]] is undefined.
$ dash -n entrypoint.sh # syntax only, but it is the real interpreter
$ dash entrypoint.sh # a real run: the most reliablecheckbashisms is the specialist and detects things ShellCheck does not flag; shellcheck -s sh integrates with the rest of the flow and explains the why; dash -n validates the syntax with the interpreter that will actually be used on Debian. But the definitive check is running it where it is going to run:
There you discover the bashisms and the BusyBox differences at the same time, and the latter are half the problem. It deserves a CI job (08-05) alongside the ShellCheck one.
- The design decision, with an explicit criterion for Veloz Envíos
Pure POSIX is not "better": it is a restriction you pay for in readability and in processes. A ${s^^} becomes a tr with its fork, and an associative array that solved an aggregation in ten lines becomes a sorted temp file. Writing in POSIX something that will always run on Ubuntu is paying a cost and buying nothing.
| POSIX is worth it | POSIX is a burden |
|---|---|
entrypoint.sh of a minimal container with no Bash |
Internal tools for a homogeneous estate |
Boot scripts and initramfs |
Scripts with data logic and aggregation |
| Installers that run on unknown systems | Automation with arrays and regular expressions |
Fragments others embed in their /bin/sh |
Everything that already works on Bash 5 |
Criterion for the toolkit, written down and applied:
lib/common.shand the five scripts inbin/remain Bash 5, with#!/usr/bin/env bash, associative arrays,[[ ]],mapfileandset -euo pipefail. They run on three identical servers we administer ourselves; giving up those tools would make the code worse for no gain at all.- The
entrypoint.shof theveloz-apicontainer is written in pure POSIX with#!/bin/sh. The base image is Alpine, it does not ship Bash, and adding it just for startup means growing the image and its attack surface. The script is short — it checks environment variables, waits for the database andexecs the service — exactly the kind of logic POSIX covers effortlessly.
#!/bin/sh
# entrypoint.sh - veloz-api startup. Pure POSIX: the Alpine image has no bash.
set -eu # no pipefail: it is not POSIX
: "${VELOZ_API_PORT:=8080}" # default value, POSIX
: "${VELOZ_DB_HOST:?VELOZ_DB_HOST is missing}" # required or abort (03-06)
waited=0
while ! nc -z "$VELOZ_DB_HOST" 5432; do
waited=$(( waited + 1 ))
[ "$waited" -ge 30 ] && { echo "the database is not responding" >&2; exit 1; }
sleep 1
done
echo "veloz-api starting on port $VELOZ_API_PORT"
exec /usr/local/bin/veloz-api --port "$VELOZ_API_PORT" "$@"Not a single bashism: [ ] instead of [[ ]], $(( )) which is POSIX, : with ${var:=} and ${var:?}, echo with no options and a final exec so the service inherits PID 1 and receives the signals (05-02). The decision is documented in the header, which is as important as the decision itself.
- Declaring the dependency: checking
BASH_VERSINFO
BASH_VERSINFOIf a script requires Bash 4 or higher, say so and fail early with a clear message, instead of blowing up fifty lines later with "syntax error near unexpected token". The check goes at the top of lib/common.sh, right after the shebang, and uses BASH_VERSINFO (01-02):
#!/usr/bin/env bash
# lib/common.sh - the toolkit's common library. Requires Bash 4.3+.
if [ -z "${BASH_VERSINFO:-}" ]; then
echo "Error: this script requires Bash, not sh/dash." >&2
exit 1
fi
if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then
echo "Error: Bash 4.3 or higher is required; found ${BASH_VERSION}." >&2
echo "On macOS: brew install bash and use /opt/homebrew/bin/bash." >&2
exit 1
fiTwo details about the code. The first check is written with [ ] and echo on purpose: if the interpreter turns out to be dash, those two lines have to work in order to deliver the message; a [[ ]] here would produce exactly the cryptic error we are trying to avoid. And the message says what to do, not just what is missing: an error that points to the solution saves half an hour for whoever hits it.
- What each Bash version brings and the macOS problem
| Version | Relevant new features | Year |
|---|---|---|
| 3.2 | The macOS one. No associative arrays, no mapfile, no ${s^^}, no &>> |
2006 |
| 4.0 | Associative arrays (declare -A), mapfile/readarray, ${s^^}/${s,,}, |& |
2009 |
| 4.2 | printf -v with %(fmt)T (dates without date), declare -g |
2011 |
| 4.3 | declare -n (name references, 08-03), improvements in [[ ]] |
2014 |
| 4.4 | ${var@Q} (safe quoting), mapfile -d, local - |
2016 |
| 5.0+ | EPOCHSECONDS and EPOCHREALTIME (time without date), SRANDOM |
2018-22 |
The practical boundary is Bash 4.0: almost everything that makes modern Bash comfortable arrived there. Ubuntu 24.04 ships 5.2, so there is no problem across the fleet.
The problem is macOS, which is still on 3.2 from 2006 because of the GPLv3 license of the later versions. If a colleague develops on a Mac, their scripts will fail when using declare -A or mapfile even though they work on the servers. Three possible answers: install a modern Bash with Homebrew and use #!/usr/bin/env bash with the new one first in the PATH — the normal option; stick to Bash 3.2, which is giving up too much; or develop inside a container with the same image as production, which additionally eliminates the rest of the tool differences from section 5.
- Module 8 in review
The module started with three questions about the toolkit: is it understandable? is it fast? is it secure? Seven lessons later, ~/veloz-ops/ is a different thing:
| Lesson | What it brought |
|---|---|
| 08-01 | Readable: uniform style, canonical structure, short functions, comments that explain the why |
| 08-02 | Fast: measured before and after; daily-report.sh from 3 min 12 s to 3.9 s |
| 08-03 | Audited: no eval, input validated with an allowlist, secrets out of the code, safe temp files, scoped sudo |
| 08-04 | Versioned: a history with reasons, branches, tags, a .gitignore that protects the configuration, a pre-commit hook |
| 08-05 | Analyzed: 47 warnings fixed, among them an rm -rf with an empty variable; automatic formatting with shfmt |
| 08-06 | Tested: tests/ with doubles and fake data, run on every commit and in CI |
| 08-07 | With its portability decided: Bash 5 documented and checked, POSIX where it is really needed |
And all of it without adding a single new feature. That is the module's idea: the difference between a script that works and software you can rely on is not in what it does, but in how it is made.
Common Mistakes and Tips
- Putting
#!/bin/shand writing Bash. The central mistake. If you use bashisms, the shebang is#!/usr/bin/env bash. - Testing with
bash script.sha script that declaressh. The shebang is ignored when you invoke it that way, and the problem stays hidden. Test withdash script.sh. - Believing POSIX is "safer" or "better". It is more restrictive: choosing it without need produces longer, slower and harder-to-read code.
- Forgetting the quotes in
[ ].[[ ]]forgave you; here an empty variable produces a syntax error. - Assuming the tools are GNU.
sed -i,date -dandreadlink -fdo not exist in the same form on macOS or BusyBox, and no shell helps you there. - Tip: an Alpine container in CI costs seconds and detects the bashisms and the BusyBox differences at the same time.
- Tip: write the decision in the file header. "Requires Bash 4.3+" or "Pure POSIX: the image has no bash" stops the next person from undoing it by accident.
Exercises
Exercise 1. Convert this fragment to pure POSIX keeping the behavior.
#!/usr/bin/env bash
cities=(Valencia Sevilla Bilbao)
for c in "${cities[@]}"; do
[[ $c == V* ]] && echo -e "priority:\t${c^^}"
doneExercise 2. Write a veloz_days_ago function that returns the date N days ago in YYYY-MM-DD format and works with both GNU and BSD date.
Exercise 3. A colleague reports that daily-report.sh fails on their macOS with declare: -A: invalid option. Explain the cause and propose two solutions with their trade-offs.
Solutions
Solution 1.
#!/bin/sh
set -- Valencia Sevilla Bilbao # positional parameters as an "array"
for c in "$@"; do
case $c in
V*) upper=$(printf '%s' "$c" | tr '[:lower:]' '[:upper:]')
printf 'priority:\t%s\n' "$upper" ;;
esac
doneFour substitutions: the array becomes set -- and "$@"; the pattern comparison [[ $c == V* ]] becomes case, which is the POSIX way of comparing against patterns; ${c^^} becomes tr; and echo -e becomes printf, which interprets \t portably and also avoids the problem of echo -e printing a literal -e in some shells.
Solution 2.
# veloz_days_ago <n> -> YYYY-MM-DD date from n days ago
veloz_days_ago() {
if date -d '@0' +%F >/dev/null 2>&1; then
date -d "$1 days ago" +%F # GNU
else
date -v "-$1"d +%F # BSD / macOS
fi
}The detection is done by trying a GNU-only option and discarding its output and its error. The truly universal alternative is epoch arithmetic — date -d "@$(( $(date +%s) - 86400 * $1 ))" — but it has the same problem converting back, and it also ignores daylight-saving changes, so variant detection is preferable.
Solution 3. The cause is that macOS ships Bash 3.2, older than the associative arrays that arrived in 4.0, and #!/usr/bin/env bash resolves to the system's /bin/bash. Two solutions:
- Install a modern Bash with
brew install bashand put its directory ahead in thePATH, so thatenv bashfinds the 5.x one. Trade-off: each person has to configure their machine, and a badly orderedPATHsilently reproduces the failure. - Develop in a container with the same image as production. Trade-off: a slightly heavier workflow; advantage: it also eliminates the
sed,dateandstatdifferences from section 5, which would show up anyway.
Rewriting the toolkit for Bash 3.2 would be a third option, and it is the worst: it penalizes three servers to accommodate one laptop. In any case, the BASH_VERSINFO check from section 8 would have turned that cryptic error into a message explaining what to do.
Conclusion
Portability is a decision, not an automatic virtue. POSIX defines the shell's lowest common denominator, and everything Bash adds on top — [[ ]], arrays, ${s//a/b}, ${s^^}, mapfile, <<<, <( ), +=, set -o pipefail, trap ERR — is a bashism that disappears the moment /bin/sh is dash on Debian, ash on Alpine or ksh on another Unix; the error [[: not found is its signature. The equivalence table covers the essentials: [ "$a" = "$b" ] with mandatory quotes, case for comparing against patterns, set -- and "$@" as a substitute for arrays, tr and sed instead of the string expansions, . instead of source, printf instead of echo -e and restructuring the code wherever pipefail is missing. But the bigger problem is not in the shell but in the external tools: sed -i, date -d, readlink -f, grep -P and stat behave differently on GNU, BSD and BusyBox, and against that there are only three strategies — stick to the POSIX options, detect the variant at startup or declare the dependency on coreutils. Portability is checked, not assumed: checkbashisms, shellcheck -s sh, dash -n and, above all, a real run in an Alpine container inside CI. Veloz Envíos' criterion is written down: lib/common.sh and the five scripts in bin/ remain Bash 5 because they run on three identical servers and giving up their tools would buy nothing, whereas the entrypoint.sh of the veloz-api container is written in pure POSIX because the Alpine image has no Bash and its logic is short. That dependency is declared and checked with BASH_VERSINFO at startup, in [ ] and echo so the message gets through even if the interpreter is dash, and saying what to do and not just what is missing. The practical boundary is Bash 4.0 — associative arrays, mapfile, ${s^^} — with declare -n in 4.3, and the recurring obstacle is macOS' Bash 3.2, which is solved with Homebrew or by developing in a container.
With this Module 8 closes, and with it the toolkit's transformation: the same five scripts and the same library that started the module are now readable, fast, audited, versioned, analyzed, tested and with their portability decided, without having added a single new feature. You already know how to write Bash and you already know how to write it well. What is left is putting it all together. Module 9 is five complete projects built step by step, applying everything learned at once: a system information collector (09-01), a log analyzer that aggregates, detects patterns and generates reports (09-02), an automated backup system with retention, verification and a tested restore (09-03), a network monitor with thresholds and alerts (09-04) and, as the course's closing piece, the final integration of the toolkit (09-05), where the pieces of the four previous projects are unified into a single tool, installable, versioned, tested and deployed across the Veloz Envíos fleet.
Bash Programming Course
Module 1: Introduction to Bash
- What Is Bash?
- Setting Up Your Environment
- Basic Command Line Navigation
- Understanding the Shell
- Finding Help: man, help and --help
Module 2: Basic Bash Commands
- File and Directory Operations
- Text Processing Commands
- File Permissions and Ownership
- Redirection and Piping
- Wildcards and Path Expansion
- History and Keyboard Shortcuts
Module 3: Scripting Fundamentals
- Creating and Running a Script
- Variables and Constants
- Basic Operators
- Conditional Statements
- Arguments and User Input
- Quoting, Expansion and Substitution
Module 4: Intermediate Scripting
- Loops in Bash
- Functions in Bash
- Arrays and Associative Arrays
- String Manipulation
- The case Statement and Interactive Menus
- Arithmetic and Numeric Calculations
Module 5: Advanced Scripting Techniques
- Advanced File Operations
- Process Management
- Error Handling and Debugging
- Regular Expressions
- Advanced I/O: Descriptors and Here-Documents
- Modular Scripts and Reusable Libraries
Module 6: Working with External Tools
Module 7: Automation and Scheduling
- Cron Jobs
- Automating Tasks
- Backup and Restore Scripts
- Monitoring and Logging
- Services and Timers with systemd
- Remote Automation with SSH
Module 8: Best Practices and Optimization
- Writing Readable Code
- Optimizing Bash Scripts
- Security Considerations
- Version Control with Git
- Static Analysis with ShellCheck and shfmt
- Automated Testing with Bats
- Portability: POSIX sh versus Bashisms
