Bash & the POSIX Shell Module 14 – Portability: POSIX sh, versions and platforms
GNU vs BSD tools: Linux and macOS differences
Find the commands whose options differ between GNU on Linux and BSD on macOS, write a portable sed -i, and detect the platform at run time.
What you will learn
- Recognise commands whose options differ between GNU and BSD
- Write scripts that run on both platforms or detect the platform
- Use Homebrew to add GNU replacements on macOS
Before you start
What you’ll learn · Before you start
What you will learn
- Recognise commands whose options differ between GNU and BSD
- Write scripts that run on both platforms or detect the platform
- Use Homebrew to add GNU replacements on macOS
Before you start
On this page
A build script that renames hosts in options.conf runs green on Ubuntu and dies on a developer’s Mac: the log ends
in invalid command code o, and the file is still as it was. The two machines read the same script and run different
programs under the names sed, grep, stat and date: macOS 26.7.1 ships BSD utilities: The command-line tools inherited from the Berkeley Software Distribution (sed, grep, stat, date, find, xargs and others). macOS and the modern BSDs ship these; some options and defaults differ from GNU's versions., and Ubuntu 24.04 LTS ships GNU coreutils: The GNU project's set of basic Unix text, file and shell utilities (ls, cp, mv, stat, date, sort and so on), under GPL-3.0. It ships by default on most Linux systems; on macOS it is a Homebrew formula that installs them with a g prefix. and friends. The two sets
agree on most of POSIX and disagree on a short list of flags and defaults.
The two ways out are to write code that runs on either side, or to add the GNU tools to the Mac. The sections below
run ten operations where the split hurts most, build a portable in-place edit for sed, use uname -s plus a
one-shot feature probe to pick the right form at run time, and set up Homebrew on the Mac side.
Why the same tool names run two different programs
macOS is built on Darwin, which Apple’s kernel guide describes as BSD and Mach at the core, with a large part taken
from FreeBSD. Its command-line tools (sed, grep, stat, date, find, xargs, readlink, zcat) descend
from the BSD versions, and its awk is BWK awk, the one-true-awk. Most Linux distributions ship the GNU versions of
the same names, written by the GNU project. Ubuntu 26.04 LTS installs the uutils rewrite of coreutils by default; it
takes the GNU options this lesson uses, so it sits on the GNU side here. Both families cover the POSIX surface, and
each adds its own extensions.
Some splits last for many years. Apple’s /bin/bash is still Bash 3.2.57, and Apple’s support page says that new
user accounts have had zsh as their default shell since macOS 10.15, while /bin/bash stayed where it was.
A session of ten common operations shows what the split costs today. Each pair puts the Linux habit next to what the
Mac’s own tools do; for date, xargs and awk the session also runs the GNU program itself (gdate, gxargs and
gawk, installed beside them):
from session import run
# Ten BSD-vs-GNU pairs run on this macOS 26.7.1 host. The BSD side is the Mac's own /usr/bin set (BSD descendants
# of the research Unix utilities). The GNU side runs where this Mac has the GNU program: gdate (coreutils 9.12),
# gxargs (findutils 4.11.0) and gawk (5.4.1), which session.py puts on PATH beside the Mac's tools. GNU sed, grep,
# stat and readlink are not run here: the lesson quotes their options from the GNU manuals and man7, and the session
# shows what the Mac does with a Linux-flavoured option and which form runs on both.
run("""
# 1. sed -i. BSD -i takes the next word as the backup suffix; GNU takes only an attached one.
printf 'cat\\n' > one.txt
!sed -i 's/cat/dog/' one.txt
cat one.txt
printf 'cat\\n' > two.txt
sed -i '' 's/cat/dog/' two.txt
cat two.txt
# An attached suffix (-i.bak) reads the same on both; delete the backup after.
printf 'cat\\n' > three.txt
sed -i.bak 's/cat/dog/' three.txt && rm three.txt.bak
cat three.txt
# 2. Epoch to a date: GNU date -d @0, BSD date -r 0.
!date -d @0
date -r 0
gdate -d @0
# 3. Octal mode: BSD stat -f %Lp; GNU stat -c %a (coreutils manual).
stat -f %Lp one.txt
# 4. grep -P is GNU-only; grep -E with a class runs on both.
printf 'abc 123\\n' > grep.txt
!grep -P '[0-9]+' grep.txt
grep -E '[0-9]+' grep.txt
# 5. find -printf is GNU-only; -exec with POSIX wc and printf runs on both.
mkdir -p pf && printf 'ab' > pf/a && printf 'xy' > pf/b
!find pf -type f -printf '%p %s\\n'
find pf -type f -exec sh -c 'for f do printf "%s %d\\n" "$f" "$(wc -c < "$f")"; done' sh {} +
# 6. zcat: the Mac's zcat reads .Z files; gzip -dc reads .gz on both.
printf 'hello\\n' | gzip -c > msg.gz
!zcat msg.gz
gzip -dc msg.gz
# 7. readlink -f runs on this Mac; older macOS releases did not have it.
ln -s msg.gz link.gz
readlink -f link.gz
# 8. xargs on empty input: GNU runs the command once unless -r; BSD skips it and accepts -r.
printf '' | gxargs echo "nothing to do"
printf '' | gxargs -r echo "nothing to do"
printf '' | xargs echo "nothing to do"
printf '' | xargs -r echo "nothing to do"
# 9. awk: the Mac ships BWK awk; gawk adds gensub and asort.
awk --version
gawk --version | head -1
# 10. Date arithmetic: BSD steps with -v, GNU reads a phrase after -d.
date -r 0 -v+1d -v-6H '+%Y-%m-%d %H:%M'
gdate -d '1970-01-01 00:00 UTC + 1 day - 6 hours' -u '+%Y-%m-%d %H:%M'
""") Output
# 1. sed -i. BSD -i takes the next word as the backup suffix; GNU takes only an attached one.
$ printf 'cat\n' > one.txt
$ sed -i 's/cat/dog/' one.txt
sed: 1: "one.txt
": invalid command code o
[exit status 1]
$ cat one.txt
cat
$ printf 'cat\n' > two.txt
$ sed -i '' 's/cat/dog/' two.txt
$ cat two.txt
dog
# An attached suffix (-i.bak) reads the same on both; delete the backup after.
$ printf 'cat\n' > three.txt
$ sed -i.bak 's/cat/dog/' three.txt && rm three.txt.bak
$ cat three.txt
dog
# 2. Epoch to a date: GNU date -d @0, BSD date -r 0.
$ date -d @0
date: illegal option -- d
usage: date [-jnRu] [-I[date|hours|minutes|seconds|ns]] [-f input_fmt]
[ -z output_zone ] [-r filename|seconds] [-v[+|-]val[y|m|w|d|H|M|S]]
[[[[mm]dd]HH]MM[[cc]yy][.SS] | new_date] [+output_fmt]
[exit status 1]
$ date -r 0
Thu Jan 1 00:00:00 UTC 1970
$ gdate -d @0
Thu Jan 1 00:00:00 UTC 1970
# 3. Octal mode: BSD stat -f %Lp; GNU stat -c %a (coreutils manual).
$ stat -f %Lp one.txt
644
# 4. grep -P is GNU-only; grep -E with a class runs on both.
$ printf 'abc 123\n' > grep.txt
$ grep -P '[0-9]+' grep.txt
grep: invalid option -- P
usage: grep [-abcdDEFGHhIiJLlMmnOopqRSsUVvwXxZz] [-A num] [-B num] [-C[num]]
[-e pattern] [-f file] [--binary-files=value] [--color=when]
[--context[=num]] [--directories=action] [--label] [--line-buffered]
[--null] [pattern] [file ...]
[exit status 2]
$ grep -E '[0-9]+' grep.txt
abc 123
# 5. find -printf is GNU-only; -exec with POSIX wc and printf runs on both.
$ mkdir -p pf && printf 'ab' > pf/a && printf 'xy' > pf/b
$ find pf -type f -printf '%p %s\n'
find: -printf: unknown primary or operator
[exit status 1]
$ find pf -type f -exec sh -c 'for f do printf "%s %d\n" "$f" "$(wc -c < "$f")"; done' sh {} +
pf/a 2
pf/b 2
# 6. zcat: the Mac's zcat reads .Z files; gzip -dc reads .gz on both.
$ printf 'hello\n' | gzip -c > msg.gz
$ zcat msg.gz
zcat: can't stat: msg.gz (msg.gz.Z): No such file or directory
[exit status 1]
$ gzip -dc msg.gz
hello
# 7. readlink -f runs on this Mac; older macOS releases did not have it.
$ ln -s msg.gz link.gz
$ readlink -f link.gz
~/demo/msg.gz
# 8. xargs on empty input: GNU runs the command once unless -r; BSD skips it and accepts -r.
$ printf '' | gxargs echo "nothing to do"
nothing to do
$ printf '' | gxargs -r echo "nothing to do"
$ printf '' | xargs echo "nothing to do"
$ printf '' | xargs -r echo "nothing to do"
# 9. awk: the Mac ships BWK awk; gawk adds gensub and asort.
$ awk --version
awk version 20200816
$ gawk --version | head -1
GNU Awk 5.4.1, API 4.1, PMA Avon 8-g1
# 10. Date arithmetic: BSD steps with -v, GNU reads a phrase after -d.
$ date -r 0 -v+1d -v-6H '+%Y-%m-%d %H:%M'
1970-01-01 18:00
$ gdate -d '1970-01-01 00:00 UTC + 1 day - 6 hours' -u '+%Y-%m-%d %H:%M'
1970-01-01 18:00
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 platform_matrix_session.py
Half of the ten pairs end with a non-zero exit status when a Linux-flavoured flag is given: sed -i stops at
invalid command code, date -d is an illegal option, grep -P prints invalid option -- P, find -printf says
unknown primary or operator, and zcat msg.gz looks for msg.gz.Z. The other five run without an error once the
macOS form is used, and the split is in the flag or in the result: stat -f instead of stat -c, readlink -f
(present on this Mac, missing on older ones), xargs on empty input, BWK awk instead of gawk, and date -v
arithmetic instead of a phrase after -d.
Two manuals for two user spaces
Each family has its own manual pages. GNU utilities are documented on gnu.org and on man7.org; macOS tools are
documented by the system’s own man pages. Pair every
flag with the manual of the implementation that will run it, not with the one that is easier to search.
Checkpoint
Check: Why GNU and BSD differ
Ten places where the flag or the default is not the same
The matrix session’s output falls into three groups: a spelling exists on one side only, the same operation takes a different spelling on each side, or the default differs when no flag is given. The table below takes eight of the commands and gives the form that runs on both. Answer the two questions to see which ones your script has to handle:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Operation | GNU (Linux) | BSD (macOS) | Form that runs on both |
|---|---|---|---|
| sed -i (in place) What it does: edit a file in place | Good sed -i 's/x/y/' f (empty suffix is optional) | Poor needs sed -i '' 's/x/y/' f; without '' it stops at an invalid command code | Good sed -i.bak, then delete the backup; or a sed_inplace function (mktemp, sed, mv) |
| date (epoch to text) What it does: print a timestamp from an epoch second | Good date -d @0 prints the epoch date | Good date -r 0 prints the same, with a different flag | Mixed pick the flag once with uname -s or a probe, inside one function |
| stat (octal mode) What it does: print the permissions as 644 or 755 | Good stat -c %a f prints 644 | Good stat -f %Lp f prints the same number | Mixed pick once with uname -s or a probe; GNU stat -f means file system status |
| grep -P What it does: match a Perl-style pattern | Good grep -P when built with PCRE | Poor rejects -P and prints the usage line | Good grep -E with a POSIX character class |
| find -printf What it does: print one record per file in a format | Good find . -printf '%p %s\n' writes one line per file | Poor '-printf: unknown primary or operator' | Good find -exec sh -c with wc -c and printf for each file |
| zcat file.gz What it does: write a gzipped file's content to stdout | Good zcat file.gz decompresses the file | Poor looks for file.gz.Z (BSD compress): 'can't stat' | Good gzip -dc file.gz or gunzip -c file.gz |
| readlink -f What it does: resolve every symlink in a path | Good readlink -f prints the fully resolved path | Mixed macOS 26.7.1 has -f; older macOS releases did not | Good realpath on a path that exists, which POSIX.1-2024 added |
| xargs on empty input What it does: run the command when nothing arrives | Mixed runs the utility once with no args unless -r is given | Good skips the utility on empty input; -r is a no-op | Good xargs -r: POSIX.1-2024 defines it and the Mac accepts it |
How the answers decide
- “Will the script run on more than one of macOS, Linux and BSD?” Yes, more than one platform and “May the script assume GNU coreutils, GNU grep and GNU sed are on PATH?” No, use what the platform ships: rules out sed -i (in place), grep -P, find -printf, zcat file.gz and readlink -f. These Linux forms fail on a stock Mac (readlink -f only on older macOS releases), so the script needs the portable form.
- “Will the script run on more than one of macOS, Linux and BSD?” Yes, more than one platform and “May the script assume GNU coreutils, GNU grep and GNU sed are on PATH?” No, use what the platform ships: adds a caveat to date (epoch to text), stat (octal mode) and xargs on empty input. The flag or the default differs on each side, so a uname -s case, a one-shot probe or xargs -r picks the form that works.
- “Will the script run on more than one of macOS, Linux and BSD?” Yes, more than one platform and “May the script assume GNU coreutils, GNU grep and GNU sed are on PATH?” Yes, GNU tools are installed: adds a caveat to sed -i (in place), grep -P, find -printf, zcat file.gz and readlink -f. The GNU forms work once the gnubin folders are first on PATH, at the cost of an install step on every Mac.
- “Will the script run on more than one of macOS, Linux and BSD?” No, one platform I control and “May the script assume GNU coreutils, GNU grep and GNU sed are on PATH?” Yes, GNU tools are installed: adds a caveat to sed -i (in place), grep -P, find -printf, zcat file.gz and readlink -f. One platform plus GNU tools is the usual Linux case; the script can use the GNU flags without a probe.
- “Will the script run on more than one of macOS, Linux and BSD?” No, one platform I control: adds a caveat to date (epoch to text), stat (octal mode) and xargs on empty input. On one platform the script may use the native flag directly; the portable form is a cost only when the Mac is in scope.
The first group is the Linux-only spellings: grep -P, find -printf, zcat on a .gz file and
sed -i script file. These stop with an error on macOS, so they are the ones a pipeline notices first. POSIX gives
sed no -i option at all, so both spellings of the in-place edit are extensions. The portable
forms are grep -E with a POSIX character class, a find … -exec sh -c '…' sh {} + loop that prints each name with
wc -c and printf, gzip -dc or gunzip -c, and for sed either an attached suffix (sed -i.bak, then delete the
backup) or a short sed_inplace wrapper (shown in the next section). The session above ran grep -E, the find
loop, gzip -dc and sed -i.bak on the Mac.
The second group is the same operation under a different flag: date -d @0 on GNU and date -r 0 on BSD, stat -c %a
and stat -f %Lp, GNU date -d '… + 1 day' and BSD date -v+1d. The BSD spelling is no safe guess on a GNU system
either: GNU stat -f reports on the file system, not on the file, so stat -f %Lp answers a different question there.
A uname -s branch or a probe chooses the right spelling, and a shell function hides the difference behind one name.
The third group is a different default or a missing extension: xargs on empty input runs the command once on GNU
and skips it on BSD; readlink -f ran on macOS 26.7.1, but older macOS releases did not have it; and BWK awk
(awk version 20200816 in the session) lacks gensub and asort, which the gawk manual marks as gawk extensions.
The xargs split raises no error at all: the script just produces a different result, so splits like it are the
ones the pipeline catches last. xargs -r closes it: GNU then skips the empty run, and the Mac’s xargs, which skips
it anyway, took -r without complaint in the session.
Key idea
A Linux-only spelling usually fails with an error on a Mac, so it shows up on the first run; a different default,
such as xargs on empty input, gives another result with no error at all, so only a test of the result finds it.
Checkpoint
Check: The commands that trip scripts up
A rewrite or a probe makes one script run on both
A script that must run on more than one platform has two tools for the splits above: a portable rewrite that uses no
Linux-only flag, and a run-time check that picks the right form. The in-place edit: A tool's mode of writing changes back to the file it read from, as sed -i and perl -i do. BSD sed on macOS needs a backup suffix after -i, either an empty '' word or an attached one such as -i.bak; the portable form writes a temp file and renames it over the source. is
the clearest case for a rewrite, because the file-swap pattern is both portable and atomic. The function below writes
the output of sed to a mktemp sibling of the source, copies the mode onto the new file, and mvs it over, so the
on-disk edit is one rename call either way:
from session import run, write
# A portable in-place edit, written as a shell function. The function writes the output of sed to a mktemp
# file in the same folder (so a rename is a cheap link swap and never crosses a filesystem), copies the mode
# of the source onto the new file, then mv's it over. It avoids the BSD-vs-GNU disagreement over sed -i
# altogether and keeps the atomic replacement any editor expects (see POSIX.1-2024 rename(2) semantics).
write("fix.sh", """#!/bin/bash
# sed_inplace sed-script file...: edits each file in place, portably.
sed_inplace() {
local script=$1; shift
local file tmp
for file in "$@"; do
tmp=$(mktemp "${file}.XXXXXX") || return 1
if ! sed "$script" "$file" > "$tmp"; then
rm -f "$tmp"; return 1
fi
chmod --reference="$file" "$tmp" 2>/dev/null || chmod "$(stat -f '%Lp' "$file")" "$tmp"
mv "$tmp" "$file"
done
}
cat > hosts << '__'
10.0.0.1 server-a server-a.old
10.0.0.2 server-b server-b.old
10.0.0.3 server-c server-c.old
__
sed_inplace 's/server-a/node1/g' hosts
sed_inplace 's/server-b/node2/g' hosts
sed_inplace 's/server-c/node3/g' hosts
cat hosts
""")
run("""
# What goes wrong without the wrapper: BSD sed -i reads 's/cat/dog/' as the backup suffix and the file name
# as the script, so one.txt stays as it was and sed stops at an "invalid command code" error.
printf 'cat\\n' > one.txt
!sed -i 's/cat/dog/' one.txt
cat one.txt
# The portable wrapper is a short shell function; here it runs three edits on a hosts file in a row:
cat fix.sh
chmod +x fix.sh
./fix.sh
""") Output
# What goes wrong without the wrapper: BSD sed -i reads 's/cat/dog/' as the backup suffix and the file name
# as the script, so one.txt stays as it was and sed stops at an "invalid command code" error.
$ printf 'cat\n' > one.txt
$ sed -i 's/cat/dog/' one.txt
sed: 1: "one.txt
": invalid command code o
[exit status 1]
$ cat one.txt
cat
# The portable wrapper is a short shell function; here it runs three edits on a hosts file in a row:
$ cat fix.sh
#!/bin/bash
# sed_inplace sed-script file...: edits each file in place, portably.
sed_inplace() {
local script=$1; shift
local file tmp
for file in "$@"; do
tmp=$(mktemp "${file}.XXXXXX") || return 1
if ! sed "$script" "$file" > "$tmp"; then
rm -f "$tmp"; return 1
fi
chmod --reference="$file" "$tmp" 2>/dev/null || chmod "$(stat -f '%Lp' "$file")" "$tmp"
mv "$tmp" "$file"
done
}
cat > hosts << '__'
10.0.0.1 server-a server-a.old
10.0.0.2 server-b server-b.old
10.0.0.3 server-c server-c.old
__
sed_inplace 's/server-a/node1/g' hosts
sed_inplace 's/server-b/node2/g' hosts
sed_inplace 's/server-c/node3/g' hosts
cat hosts
$ chmod +x fix.sh
$ ./fix.sh
10.0.0.1 node1 node1.old
10.0.0.2 node2 node2.old
10.0.0.3 node3 node3.old
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 inplace_session.py
The session shows the error on the first sed -i call (invalid command code o, file unchanged), then runs the
wrapper three times against a hosts file. The output is 10.0.0.1 node1 node1.old, 10.0.0.2 node2 node2.old and
10.0.0.3 node3 node3.old. The wrapper never uses -i: it needs only plain sed, mktemp, chmod (with stat to
read the mode on BSD) and mv, so a GNU system runs the same three edits, and unlike sed -i.bak it leaves no backup
file to delete.
Not every split has a rewrite that fits in five lines. For date and stat the simplest answer is a run-time check:
ask the system once what its tool does, and define a wrapper with the form that worked. The four stages below show
the pattern on sed -i, the split the lesson has already met:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- Name the kernel. uname -s prints 'Darwin' on macOS and 'Linux' on Linux; a case picks a platform label the rest of the script can read. It is a cheap first filter but not the final word, because a Mac with GNU sed first on PATH is a Linux-flavoured environment too.
- Probe one feature. A one-byte file x is written to a temporary path, the GNU form of sed -i is tried on it, and the probe reads the file back. If it now holds y, the shell's sed takes the GNU form; if not, the BSD one. Feature detection beats the name alone when the same box has both tool families.
- Define the wrapper. The script defines sed_i script file once. Its body is sed -i script file on GNU and sed -i '' script file on BSD, so every call site can forget the difference. The function is the API the rest of the script talks to.
- Run the edit. A real edit runs through the wrapper. On this Mac the session printed 'running on macOS', then 'sed -i form = BSD', then the edited file's new content, after.
The last stage of the flow is a real edit under the wrapper. The session below runs it on this Mac and prints which form it chose:
from session import run, write
# A short platform-detect snippet: print which platform the script is on and which sed -i form it will use.
# The point is twofold: (1) uname -s names the kernel in a reliable way (Linux, Darwin, FreeBSD, OpenBSD,
# NetBSD, SunOS) that scripts can split on; (2) a feature check that tries the GNU form on a throwaway file
# is more honest than a name check, because a Linux box with /usr/bin/sed pointing at BSD sed exists too.
write("detect.sh", """#!/bin/bash
# Print which platform this is, and which sed -i form the script will use after a one-shot feature probe.
case "$(uname -s)" in
Darwin) platform=macOS ;;
Linux) platform=Linux ;;
FreeBSD) platform=FreeBSD ;;
*) platform=unknown ;;
esac
printf 'running on %s\\n' "$platform"
# Feature-detect: write a one-byte file and try the GNU form on a copy. The one that works is the one the
# function will use for the rest of the script.
probe=$(mktemp)
printf 'x' > "$probe"
if sed -i 's/x/y/' "$probe" 2>/dev/null && [ "$(cat "$probe")" = y ]; then
form='GNU: sed -i script file'
sed_i() { sed -i "$1" "$2"; }
else
form="BSD: sed -i '' script file"
sed_i() { sed -i '' "$1" "$2"; }
fi
rm -f "$probe"
printf 'sed -i form = %s\\n' "$form"
# One real edit, using the form the probe chose:
printf 'before\\n' > note
sed_i 's/before/after/' note
cat note
""")
run("""
chmod +x detect.sh
./detect.sh
""") Output
$ chmod +x detect.sh $ ./detect.sh running on macOS sed -i form = BSD: sed -i '' script file after
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 platform_detect_session.py
The output on this Mac is running on macOS, sed -i form = BSD: sed -i '' script file, then after on the next
line (the file was edited). A Mac developer who has Homebrew: A package manager for macOS and Linux. Its formulae install under /opt/homebrew (Apple silicon) or /usr/local, and GNU replacements of macOS tools (coreutils, grep, gnu-sed, gawk, findutils) are installed with a g prefix on the binary name.’s GNU sed first on PATH
sees sed -i form = GNU: sed -i script file on the same probe, because the probe reads what the shell’s sed really
does, not what the kernel is called.
Trusting uname -s alone
Wrong: case $(uname -s) in Darwin) …;; Linux) …;; esac as the only decision; this misses a Mac with GNU sed
first on PATH, and a FreeBSD host that matches neither branch. Right: uname -s as a first filter, followed by a
feature probe that reads the real behaviour on a one-byte file.
Checkpoint
Check: Writing code that runs on both
Homebrew adds the GNU tools under g-prefixed names
The second way out of the split is to add the GNU side to macOS through Homebrew.
Homebrew’s coreutils formula ships the GNU replacements of ls, cp, date, stat, du, wc and the rest, and
findutils, grep, gnu-sed and gawk do the same for their namesakes. To avoid clashing with the macOS tools of
the same name, Homebrew installs each command that macOS also has with a g prefix, so GNU date is gdate, GNU
sed is gsed and GNU find is gfind:
| Formula | Binary names installed | What it covers |
|---|---|---|
coreutils |
gdate, gls, gstat, gcp, … |
GNU file, text and shell utilities |
gnu-sed |
gsed |
GNU sed, with GNU’s optional -i suffix |
grep |
ggrep |
GNU grep, with -P |
gawk |
gawk |
GNU awk, with gensub and strtonum |
findutils |
gfind, gxargs |
GNU find and xargs, with -printf, -r |
The install step is brew install coreutils gnu-sed grep gawk findutils bash; each formula has its own page on
formulae.brew.sh. The bash formula installs a current Bash 5 release at $HOMEBREW_PREFIX/opt/bash/bin/bash, next
to the old /bin/bash, which it does not replace.
The next decision is where to put the Homebrew binaries on PATH. The notes of the coreutils and gnu-sed formulae say how: each comes with a libexec/gnubin folder that holds the
same commands under their normal names, so gsed is also sed there. Adding every gnubin folder first on PATH
makes the Mac behave like a Linux box for sed, grep and date. That hides a bug that a stock Mac would show. The honest default is to keep /usr/bin ahead of the gnubin folders, and reach for
gsed, ggrep or gdate by their g-prefixed names when the script really wants GNU, so plain sed is still the
BSD one.
One PATH policy for the whole team
A Brewfile (one brew "…" line per formula, installed with brew bundle) and a .zprofile snippet that sets the
same PATH on every laptop keep the team’s Macs the same. New joiners run brew bundle once; the Mac side of the
pipeline then matches, and a build that uses a Linux-only flag fails on the laptop before it reaches main.
Checkpoint
Check: GNU tools on macOS with Homebrew
The places where POSIX caught up
The split is not static. Each new edition of POSIX folds in a few extensions that one family or one shell already had, and the shells and tools follow at their own pace. POSIX.1-2024 (IEEE Std 1003.1-2024, Open Group Base Specifications Issue 8) is the latest edition, and four of its changes shrink the list of splits a portable script has to handle:
| POSIX.1-2024 adopts | Before Issue 8 | Portable form after adoption |
|---|---|---|
realpath as a utility |
each family’s own realpath, or readlink -f |
realpath on a path that exists |
xargs -r and -0, find -print0 |
extensions that GNU and BSD both had | find … -print0 into xargs -0 -r |
test -nt, -ot, -ef |
an extension of most shells | [ a -nt b ] |
set -o pipefail |
an extension of Bash and other shells | set -o pipefail after a probe |
realpath is on both macOS 26.7.1 and GNU coreutils 9.12, so the readlink -f wrapper in older scripts can be
retired as soon as the oldest system the script runs on has a 2024-standard realpath. Until then, call it on paths
that exist: the Mac’s realpath takes only -q, not the standard’s -e and -E, and it fails on a missing file
where GNU’s prints the resolved name anyway. The standard’s xargs runs the command once on empty input unless -r is
given, as GNU’s does, so xargs -r is the spelling every side now agrees on. test -nt is handy inside make-like scripts ([ build/out.o -nt src/out.c ] || compile src/out.c); Bash and dash
already ran it in the probes of the POSIX.1-2024 lesson, and the standard
now promises it. set -o pipefail is now in the standard and ran in dash 0.5.13.5, so a POSIX script may turn it on
without a Bash first line, after the probe that lesson shows.
Key idea
The matrix shrinks every few years as POSIX folds in extensions; a wrapper written today for readlink -f can be
removed in the release that drops support for the last system without realpath.
Checkpoint
Check: Where the standard caught up
Practise: the splits in your own script
Most questions give a line from a Linux-flavoured script and ask what macOS does with it, or the other way around. Work each one out from the recorded output of the sessions above before you check your choice:
Practice that adapts to you
Practise this lesson
12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): a Linux script uses sed -i to edit a file in place and fails on macOS. What is the smallest change that runs on both?
GNU sed -i treats the backup suffix as optional and accepts one only when it is attached to -i; BSD sed on
macOS needs a suffix and takes the next word, so sed -i 's/x/y/' f takes 's/x/y/' as the suffix, the file name as
the script, and stops with invalid command code. The smallest change that runs on both is to attach the suffix,
sed -i.bak 's/x/y/' f, then delete f.bak: the matrix session ran that form on the Mac, and GNU reads an attached
suffix the same way. The cleaner change is a short wrapper function that writes sed’s output to a mktemp sibling
of the file, copies the mode onto it and mvs it over, so neither platform sees -i and no backup is left behind.
Two harder questions follow: one about a port to the Mac that exits 0 and still gives a different result, and one about a date probe that picks the wrong form on a single Linux host. Sketch how you would start on each before you read its first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to staff
A config step ported from Ubuntu exits 0 on a stock macOS runner, but each run leaves the old version of every edited file with -e added to its name, and its summary shows seven spaces before the host count where Ubuntu prints
hosts=3. Find both splits and make the Mac match Ubuntu. -
Fresher to staff
A lib.sh picks its date form: if
date -u -r 0succeeds, it wraps the BSD formdate -u -r "$1", else the GNU formdate -u -d "@$1". Macs work, but on one Ubuntu agent each call printsNo such file or directoryand the report's dates are empty. Explain why and rewrite the decision.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why can the same command name behave differently on Linux and macOS?
macOS ships utilities descended from BSD, while most Linux systems ship GNU coreutils or tools that take its options. They agree on most of POSIX and differ on a short list of flags and defaults.
-
Which GNU-versus-BSD differences are hardest to catch?
A different default rather than a missing flag. A Linux-only spelling such as grep -P, find -printf or date -d fails with an error on a Mac, but xargs on empty input gives a different result with no error, so only a test of the result finds it.
-
What is the shortest sed in-place edit that GNU and BSD sed read the same way?
sed -i.bak 's/x/y/' file, with the backup suffix attached to -i. A wrapper that writes a mktemp file and moves it over the original avoids -i altogether.
-
How does a script choose between GNU and BSD spellings?
uname -s is the usual first filter, and a one-off feature probe is the reliable second step, because a Mac may have the GNU tools first on PATH.
-
How does Homebrew add the GNU tools to a Mac?
Its coreutils, gnu-sed, grep, gawk and findutils formulae install them with a g prefix, such as gsed; keeping /usr/bin ahead of their gnubin folders keeps the stock tools as the default.
-
Which GNU-only features did POSIX.1-2024 make standard?
realpath, xargs -r, test -nt and set -o pipefail, so some portability wrappers can go once every target system has a 2024-standard shell and tools.
Key takeaways
- macOS ships BSD-descended utilities and most Linux distributions ship GNU coreutils or a rewrite that takes its options; the two sets agree on most of POSIX and disagree on a short list of flags and defaults, so a Linux-flavoured script often fails on a stock Mac.
- Five of the ten pairs in the matrix session end with a non-zero exit on macOS (
sed -i,grep -P,find -printf,zcat file.gz,date -d); the other five need the macOS spelling or give a different result, and thexargspair differs without any error. sed -i.bakis the shortest in-place form that both sides read the same way, and ased_inplacewrapper that writes amktempfile, copies the mode andmvs it over avoids-ialtogether and keeps the edit atomic.uname -sis the usual first filter for a platform split, and a one-shot feature probe is the honest second step when a Mac may have GNU tools first onPATH.- Homebrew’s
coreutils,gnu-sed,grep,gawkandfindutilsformulae install the GNU replacements with agprefix; keeping/usr/binahead of theirgnubinfolders lets the Mac still show what a vanilla Mac does. - POSIX.1-2024 folded in
realpath,xargs -r,test -ntandset -o pipefail, so some wrappers written today will become deletable once every target has a 2024-standard shell and utility set.
Terms in this lesson
- BSD utilities
- The command-line tools inherited from the Berkeley Software Distribution (sed, grep, stat, date, find, xargs and others). macOS and the modern BSDs ship these; some options and defaults differ from GNU's versions.
- GNU coreutils
- The GNU project's set of basic Unix text, file and shell utilities (ls, cp, mv, stat, date, sort and so on), under GPL-3.0. It ships by default on most Linux systems; on macOS it is a Homebrew formula that installs them with a g prefix.
- Homebrew
- A package manager for macOS and Linux. Its formulae install under /opt/homebrew (Apple silicon) or /usr/local, and GNU replacements of macOS tools (coreutils, grep, gnu-sed, gawk, findutils) are installed with a g prefix on the binary name.
- in-place edit
- A tool's mode of writing changes back to the file it read from, as sed -i and perl -i do. BSD sed on macOS needs a backup suffix after -i, either an empty '' word or an attached one such as -i.bak; the portable form writes a temp file and renames it over the source.
References
- GNU sed manual (The GNU Project (Free Software Foundation))
- date(1): print or set the system date and time (Linux man-pages project)
- stat(1): display file or file system status (Linux man-pages project)
- grep(1): print lines that match patterns (Linux man-pages project)
- find(1): search for files in a directory hierarchy (Linux man-pages project)
- GNU Coreutils manual: realpath invocation (The GNU Project (Free Software Foundation))
- POSIX.1-2024: sed (The Open Group and IEEE)
- POSIX.1-2024: realpath (The Open Group and IEEE)
- POSIX.1-2024: xargs (The Open Group and IEEE)
- POSIX.1-2024: find (The Open Group and IEEE)
- POSIX.1-2024: test (The Open Group and IEEE)
- Kernel Programming Guide: Kernel Architecture Overview (Apple)
- Use zsh as the default shell on Mac (Apple)
- Ubuntu package: coreutils (noble, 24.04 LTS) (Ubuntu (Canonical))
- Ubuntu package: coreutils (resolute, 26.04 LTS) (Ubuntu (Canonical))
- The GNU Awk User's Guide: String-Manipulation Functions (The GNU Project (Free Software Foundation))
- awk(1): pattern-directed scanning and processing language (The FreeBSD Project)
- Homebrew formula: coreutils (Homebrew)
- Homebrew formula: gnu-sed (Homebrew)
- Homebrew formula: bash (Homebrew)
- Homebrew Bundle, brew bundle and Brewfile (Homebrew)
Report a problem with this lesson
Kept only in this browser. Your Learn progress