macOS vs Linux: Bash Version Traps and Coreutils Differences
AI generated
$_
#!/
Bash · macOS · Linux · Coreutils
macOS vs Linux
Bash version traps and coreutils differences

A script using mapfile or sed -i runs fine on the Linux server and breaks on a colleague's MacBook. The reason is the outdated Bash 3.2 that ships with macOS and the different coreutils Apple provides for licensing reasons.

17 min read Bash 3.2 · Homebrew · GNU vs BSD coreutils macOS · Linux · CI

1. The problem: same code, two behaviors

A developer writes a deployment script on a Linux server, tests it successfully there, and hands it to a colleague with a MacBook. The script uses mapfile to read lines into an array and immediately fails on the Mac with command not found. This is not an edge case, it is one of the most common causes of Bash version traps between macOS and Linux: Apple has shipped the same outdated Bash version for years, while Linux distributions have long since updated to Bash 5.x.

On top of the plain Bash version, differences in coreutils pile on, meaning the basic command line tools such as sed, date, stat and readlink. macOS is based on BSD Unix and ships BSD variants of these tools, Linux uses GNU coreutils. A script meant to run reliably on both platforms therefore has to dodge two separate traps at once: the Bash version trap and the coreutils traps. Together they are the main reason why supposedly simple shell scripts regularly fail in mixed macOS and Linux teams.

2. Why macOS stays on Bash 3.2

The reason for the outdated Bash version on macOS is purely a licensing one. Bash switched from the GPLv2 to the GPLv3 license starting with version 4.0. Apple consistently avoids GPLv3 software in the operating system because the license contains patent clauses Apple is not willing to accept legally. That is why macOS has stayed on Bash 3.2, the last version under GPLv2, for roughly 15 years, and will likely never preinstall a newer Bash version.

This decision is not limited to Bash. Other GNU tools like grep, sed and the coreutils as a whole are either replaced by BSD implementations under macOS or frozen at older versions. Anyone developing on macOS and writing scripts for a Linux target server is effectively working with a Bash version last updated in 2006, while the target system offers significantly more with Bash 5.x. This gap between macOS and Linux is not a temporary problem but a permanent structural difference.

3. Everything missing since Bash 3.2

The list of features missing between Bash 3.2 on macOS and Bash 5.x on Linux is long. Associative arrays with declare -A were only introduced in Bash 4.0 and simply do not exist under the macOS default Bash. mapfile or readarray for reading lines into an array likewise arrived only with Bash 4.0. Name references with local -n, which allow writing into a caller's variable, were only added in Bash 4.3.

wait -n, which waits for the first finished background job instead of all of them, is also completely missing under macOS Bash 3.2. The parameter expansions ${var,,} and ${var^^} for case conversion only exist from Bash 4.0 onward. Anyone developing a script on a Linux system and using these features produces either syntax errors on an unmodified macOS system or, worse, a script that keeps running with older, different semantics and produces wrong results without failing visibly.


#!/usr/bin/env bash
set -euo pipefail

# All three lines fail under the pre-installed macOS Bash 3.2
declare -A config              # associative arrays: Bash 4.0+
mapfile -t lines < server_list.txt   # mapfile: Bash 4.0+
echo "${lines[0]^^}"            # case conversion: Bash 4.0+

# Guard clause: fail fast with a clear message instead of a cryptic error
if ((BASH_VERSINFO[0] < 4)); then
  echo "[ERROR] This script requires Bash 4.0+, found ${BASH_VERSION}" >&2
  echo "        On macOS: brew install bash" >&2
  exit 1
fi

4. Installing and using Homebrew Bash correctly

The usual solution for macOS developers is to install a current Bash version alongside the system version via Homebrew. brew install bash installs Bash 5.x to /opt/homebrew/bin/bash on Apple Silicon or /usr/local/bin/bash on Intel Macs, without replacing the system version under /bin/bash. This matters because macOS itself still relies internally on the old Bash 3.2 under /bin/bash, and that should not be overwritten.

For scripts to actually use the new Bash version, the shebang must explicitly read #!/usr/bin/env bash, not #!/bin/bash, since env respects the current PATH order and uses whichever Bash it finds first. It is also important to place /opt/homebrew/bin before /usr/bin in the PATH variable, otherwise the old system version keeps winning despite the installation. A simple test with bash --version immediately shows which version is actually active.


# Install a modern Bash via Homebrew (Apple Silicon path shown)
brew install bash

# Make sure the Homebrew path comes BEFORE /usr/bin in PATH
echo 'export PATH="/opt/homebrew/bin:$PATH"' >> ~/.zprofile
source ~/.zprofile

# Verify which bash actually gets picked up
which bash            # should print /opt/homebrew/bin/bash
bash --version         # should show 5.x, not 3.2

# Always use env in the shebang so PATH order is respected
# #!/usr/bin/env bash   <- correct
# #!/bin/bash           <- wrong, hardcodes the outdated system version

5. GNU vs BSD coreutils: sed, date and stat

Besides the Bash version itself, the second big difference between macOS and Linux is the origin of the coreutils. Linux distributions ship GNU coreutils, macOS ships BSD variants of the same commands with partially different option syntax. The best known example is sed -i: with GNU sed, sed -i 's/old/new/' file.txt is enough, with BSD sed the same command strictly requires a suffix argument, sed -i '' 's/old/new/' file.txt, otherwise BSD sed misinterprets the next argument as a backup suffix and overwrites the wrong file.

Similar differences exist for date: GNU date understands date -d "yesterday", BSD date on macOS instead requires date -v-1d. For stat, the entire format flag differs: GNU stat uses --format, BSD stat -f. readlink -f, which fully resolves symbolic links, does not exist by default under macOS BSD readlink at all. These differences affect exactly the commands most commonly used in deployment and backup scripts, and are therefore an underestimated source of errors between macOS and Linux.


#!/usr/bin/env bash
set -euo pipefail

# GNU sed (Linux): works directly
# sed -i 's/foo/bar/' file.txt

# BSD sed (macOS): requires an explicit (possibly empty) backup suffix
# sed -i '' 's/foo/bar/' file.txt

# Portable helper: detect GNU vs BSD sed and call the right form
sed_inplace() {
  if sed --version >/dev/null 2>&1; then
    sed -i "$@"        # GNU sed
  else
    sed -i '' "$@"     # BSD sed on macOS
  fi
}
sed_inplace 's/foo/bar/' file.txt

# date: GNU vs BSD syntax differ completely
# GNU:  date -d "yesterday" +%Y-%m-%d
# BSD:  date -v-1d +%Y-%m-%d

6. coreutils with a g prefix or GNUBIN in PATH

Instead of rewriting every single command with its own detection logic, developers on macOS often install the GNU coreutils additionally via Homebrew: brew install coreutils gnu-sed findutils. By default these tools are installed with a g prefix, meaning gsed, gdate, gstat, to avoid name collisions with the BSD originals. Scripts that consistently use gsed instead of sed and gdate instead of date then run on macOS with exactly the same behavior as on Linux.

A more elegant alternative for developer machines is to put Homebrew's GNUBIN directory at the front of the PATH variable, for example /opt/homebrew/opt/coreutils/libexec/gnubin. The same commands live there without a g prefix, so sed, date and stat call the GNU variant system wide. That is convenient for local development, but it carries the risk that a script without this PATH entry falls back to the BSD variant again on a fresh Mac, which is why production scripts still need explicit detection logic.

7. Detecting version traps automatically

A guard at the start of a script that checks the Bash version is the simplest safeguard against version traps. The built in array BASH_VERSINFO can be read without an external dependency, and if a minimum version is not met, the script should abort immediately with a clear error message instead of failing later with a cryptic syntax error. This check should sit at the top of every script that uses Bash 4.0 or newer features.

For coreutils differences, a detection function that checks via the --version flag whether GNU or BSD tools are present, and then chooses the matching call syntax, is recommended. ShellCheck does not detect coreutils version traps, but it does catch many Bash version traps indirectly when modern syntax is combined with a declared target Bash that is too old. The combination of a version guard and a coreutils detection function makes a script robust against the most common differences between macOS and Linux.

8. Strategy for teams with mixed environments

Teams where developers work on macOS but deploy to Linux servers should solve the Bash version trap structurally instead of handling it individually in every script. One option is to mandate a minimum Bash version during team onboarding and document brew install bash as a required setup step. A more robust solution is running development and test environments entirely inside Docker containers with the same Linux base as production, so macOS specifics no longer matter for the actual script execution at all.

In the CI pipeline, every script should additionally be tested on the target platform, not just on the developer machine. A GitHub Actions workflow with a macOS runner matrix alongside the Linux matrix reliably surfaces version and coreutils differences before they cause problems in production. This dual test coverage is the most effective protection against macOS specific Bash and coreutils traps, because it makes the problem visible before a colleague discovers it manually on their own Mac.

9. macOS vs Linux side by side

The following table compares the most important differences between the macOS and the Linux default toolset.

Area macOS (default) Linux (default) Recommendation
Bash version 3.2 (2006, GPLv2) 5.x (current, GPLv3) brew install bash, adjust PATH
sed -i suffix argument required suffix optional detection function or gsed
date syntax date -v-1d date -d "yesterday" use gdate from coreutils
readlink -f not available standard greadlink -f or Homebrew coreutils
Associative arrays not available (3.2) standard (4.0+) version guard with BASH_VERSINFO

The comparison shows that the differences between macOS and Linux are not a footnote, they affect exactly the tools most commonly used in automation. Anyone keeping this table in mind and consistently applying version guards plus coreutils detection functions avoids most surprises between macOS and Linux before they surface in production.

Mironsoft

Shell automation, DevOps tooling and cross platform scripts

Scripts that behave identically on macOS and Linux?

We build version guards, coreutils detection functions and CI matrices so a deployment script produces the same result on a developer's MacBook and on the Linux server.

Compatibility audit

Find Bash version dependencies and coreutils calls across the script inventory

Refactoring

Add version guards and detection functions for sed, date and stat

CI matrix

Test macOS and Linux runners in parallel before colleagues find the problem manually

10. Summary

The Bash version trap between macOS and Linux is not a temporary problem, it is a permanent consequence of Apple's rejection of the GPLv3 license since Bash 4.0. macOS still ships Bash 3.2 today, while Linux distributions have long since moved to Bash 5.x. Anyone using modern Bash features like associative arrays, mapfile or wait -n must either guard against this gap with a version check or provide a current Bash version via Homebrew, with its path consistently placed before the system version in PATH.

At the same time, BSD coreutils on macOS create a second, often underestimated source of errors with sed, date, stat and readlink. Detection functions that distinguish between GNU and BSD tools, or consistently using the Homebrew GNU coreutils with a g prefix, solve this problem robustly. Anyone who keeps both traps, Bash version and coreutils, in view at the same time and tests on both platforms in the CI pipeline reliably avoids the most common surprises between macOS and Linux.

macOS vs Linux: the essentials at a glance

Root cause

Apple avoids GPLv3, so macOS stays on Bash 3.2. Linux distributions have long shipped Bash 5.x.

Missing features

Associative arrays, mapfile, wait -n and ${var,,} are completely absent from macOS default Bash.

Coreutils

sed -i, date and stat differ in option syntax between BSD (macOS) and GNU (Linux).

Solution

brew install bash coreutils, a version guard with BASH_VERSINFO, and a CI matrix with macOS and Linux runners.

11. FAQ: macOS vs Linux Bash Version Traps

1Why does macOS ship such an old Bash?
Apple avoids GPLv3 due to patent clauses, so macOS stays on the last GPLv2 version, 3.2, from 2006.
2How do I install a current Bash on macOS?
brew install bash, Homebrew path before /usr/bin in PATH, shebang #!/usr/bin/env bash instead of #!/bin/bash.
3Which features are missing under macOS default Bash?
Associative arrays, mapfile, wait -n, local -n and case conversion expansion, all only from Bash 4.0.
4Why does sed -i behave differently?
BSD sed on macOS requires a suffix argument for -i, GNU sed on Linux does not. Use a detection function or gsed.
5GNU vs BSD coreutils, what is the difference?
Different option syntax for date, stat, readlink. macOS uses BSD tools, Linux uses GNU tools.
6Should I install GNU coreutils on macOS?
Yes for developers with many Linux scripts, brew install coreutils gnu-sed findutils, access via g prefix or GNUBIN.
7How do I detect GNU vs BSD automatically?
A --version check: GNU tools respond with GNU in the name, BSD tools do not. A wrapper function then chooses the syntax.
8How do I check the Bash version in a script?
Check BASH_VERSINFO[0] and abort with a clear message if too old, instead of risking a syntax error.
9Is Docker enough against the macOS Bash trap?
Yes, if scripts only run inside a Linux container, the macOS host Bash version becomes irrelevant.
10Why does the problem often surface late?
CI runners mostly use Linux with a current Bash, the macOS specific behavior only appears on a real Mac or in a macOS CI matrix.