Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with Bash 5.3.20, dash 0.5.13.5, shellcheck 0.11.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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):

Ten BSD-vs-GNU pairs on macOS 26.7.1 Python · platform_matrix_session.py
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

  1. Question 1 of 2 On macOS 26.7.1 /usr/bin/sed, /usr/bin/awk and /usr/bin/stat come from which family of utilities?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: BSD, with much of it taken from FreeBSD

    The matrix session shows it on this Mac: sed, grep, find and date reject the Linux-flavoured flags, and awk reports a BWK version, awk version 20200816.

  2. Question 2 of 2 A teammate says a Mac has Bash 5 at /bin/bash because zsh became the default shell. What is right?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: /bin/bash is still Bash 3.2.57; zsh is the default shell for new accounts since macOS 10.15

    Apple's support page names zsh as the default shell from macOS 10.15, and /bin/bash still reports 3.2.57; the Homebrew bash formula is the usual way to get a newer Bash next to it.

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:

Interactive · answer and compare Which form of the command runs on macOS and on Linux? Answer two questions about your script. The table marks the forms that run on both and says why the others do not.
Will the script run on more than one of macOS, Linux and BSD?
May the script assume GNU coreutils, GNU grep and GNU sed are on PATH?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Eight commands against GNU and BSD, with the portable form that runs on both
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

  1. Question 1 of 3 What does /usr/bin/sed -i 's/cat/dog/' one.txt do on macOS 26.7.1?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: stops with 'invalid command code o' and leaves one.txt as it was

    The matrix recorded invalid command code o with exit status 1, and cat one.txt still printed cat.

  2. Question 2 of 3 A Linux script uses date -d @0 to format the epoch. Which flag reads the epoch on macOS?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: date -r 0

    The matrix printed the same line, midnight UTC at the start of 1970, from both date -r 0 (BSD) and gdate -d @0 (GNU).

  3. Question 3 of 3 grep -P '[0-9]+' file ran under /usr/bin/grep on macOS. What happens?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: 'invalid option -- P' and the usage line

    grep -E '[0-9]+' is the portable form, which the matrix also recorded on the same input.

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:

A portable in-place edit with mktemp, sed and mv Python · inplace_session.py
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:

Interactive · stage by stage Detect the platform at run time, then pick the command Step through the four stages: name the kernel, probe one feature, define the wrapper and run the edit.
Detect the platform at run time, then pick the command, step 4 of 4. Showing uname -s (Darwin, Linux, FreeBSD, …), probe one feature (write x, sed -i, read back), define sed_i (the API every call site uses), run the edit ('before' becomes 'after'). In focus: run the edit.uname -sDarwin, Linux, FreeBSD, …probe one featurewrite x, sed -i, read backdefine sed_ithe API every call site usesrun the edit'before' becomes 'after'

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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:

A platform probe that picks the sed -i form Python · platform_detect_session.py
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

  1. Question 1 of 2 Which three steps let a shell function edit a file in place on both macOS and Linux?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: write sed output to mktemp, chmod to the source's mode, mv over the source

    The inplace session recorded three edits on a hosts file in a row with the wrapper and no sed -i flag.

  2. Question 2 of 2 What does uname -s print, and why is it the usual first filter in a portable script?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: the kernel name, such as Darwin on macOS and Linux on Linux

    The detect session's case printed 'running on macOS' on this box.

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

  1. Question 1 of 2 brew install coreutils installed GNU date, ls, stat and so on. What is each binary called?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: with a leading g: gdate, gls, gstat

    The coreutils formula's notes say that the commands macOS also provides are installed with the prefix g, and that a gnubin folder holds them under their usual names.

  2. Question 2 of 2 A Mac developer adds gnubin folders of coreutils, findutils and gnu-sed first on PATH. What is the cost?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: their Mac now behaves like Linux, so a bug the Mac would show on BSD is masked locally

    Install the GNU set but keep the stock PATH, and run gsed when you want GNU, so the Mac still shows what a vanilla Mac does.

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

  1. One question POSIX.1-2024 added realpath as a utility. Why is that the long-term answer to readlink -f?

    Choose one answer.

    Show the answer: One question

    Answer: realpath is now in the standard, and POSIX readlink has no -f

    The Open Group's realpath page defines the utility; macOS 26.7.1 and GNU coreutils both ship one today, and the Mac's takes only -q so far.

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.

  1. Question 1 of 12 macOS is based on which operating system family?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: Darwin, a BSD-derived Unix

    Apple's kernel guide describes Darwin as based on BSD and Mach, and the detect session's case statement took the Darwin branch on this Mac.

  2. Question 2 of 12 A script written against the man7.org pages fails on the author's Mac. What is the fastest explanation?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: man7 documents GNU/Linux user space; the Mac runs BSD-descended tools

    Pair each flag with its own manual: GNU sed on gnu.org or man7.org, the Mac's sed in its own man page.

  3. Question 3 of 12 Which pair prints the octal mode of a file on each platform?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: macOS: stat -f %Lp f; Linux: stat -c %a f

    The matrix printed 644 with stat -f %Lp on this Mac and quotes the GNU form from the coreutils page.

  4. Question 4 of 12 A script runs zcat log.gz on macOS and reports "can't stat: log.gz (log.gz.Z)". Why?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: macOS zcat follows BSD compress and expects a .Z file, not a .gz one

    gzip -dc or gunzip -c decompresses the .gz file on both platforms; the matrix recorded the gzip -dc output.

  5. Question 5 of 12 A script pipes an empty file into xargs rm -f. On macOS, no file is deleted. On Linux without -r?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: xargs runs rm -f once with no operands and that call does nothing, but a different command could do harm

    Pass -r on both: GNU then skips the empty run, the Mac skips it anyway and accepts -r, and POSIX.1-2024 now defines -r.

  6. Question 6 of 12 Which pattern matches macOS in a case $(uname -s) in … esac?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: Darwin

    The detect session's case ran the Darwin branch and the printed line said 'running on macOS'.

  7. Question 7 of 12 Why does the detect session probe sed -i on a one-byte file instead of trusting uname -s alone?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: a Mac with GNU sed first on PATH behaves like Linux for sed, so the kernel name alone would mislead

    The probe runs the GNU form once on a throwaway file; if the file is edited, the script has GNU sed, whatever uname -s says.

  8. Question 8 of 12 What does the sed_inplace wrapper of the inplace session add to each edit?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: one mktemp, one mode copy and one mv per file, while sed still runs once per file

    The inplace session recorded three edits of a hosts file with no sed -i flag and the file ended with node1, node2, node3.

  9. Question 9 of 12 Which Homebrew formulae install GNU sed, grep, coreutils, awk and find under the g prefix?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: gnu-sed, grep, coreutils, gawk and findutils

    formulae.brew.sh lists these five formulae and the g-prefixed commands each one installs.

  10. Question 10 of 12 A team wants its Mac developers to catch GNU-only scripts before they ship. Which PATH policy fits?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: keep /usr/bin ahead of Homebrew's gnubin folders, so plain sed and grep are the BSD ones

    Reach for the g-prefixed name when you really want GNU; plain names still run the Mac's own tools, so a GNU-only option fails on the laptop first.

  11. Question 11 of 12 POSIX.1-2024 added which utility to replace readlink -f in a portable way?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: realpath

    The Open Group page defines realpath and lists its options; macOS 26.7.1 and GNU coreutils 9.12 both ship it.

  12. Question 12 of 12 Many shells already had test -nt and -ot as extensions. What changed in POSIX.1-2024?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: POSIX added -nt, -ot and -ef to test, and so to its [ form

    POSIX.1-2024's test page lists -nt, -ot and -ef (Austin Group Defect 375), so a /bin/sh script may rely on them wherever the shell follows Issue 8.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to staff

    A lib.sh picks its date form: if date -u -r 0 succeeds, it wraps the BSD form date -u -r "$1", else the GNU form date -u -d "@$1". Macs work, but on one Ubuntu agent each call prints No such file or directory and the report's dates are empty. Explain why and rewrite the decision.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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 the xargs pair differs without any error.
  • sed -i.bak is the shortest in-place form that both sides read the same way, and a sed_inplace wrapper that writes a mktemp file, copies the mode and mvs it over avoids -i altogether and keeps the edit atomic.
  • uname -s is 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 on PATH.
  • Homebrew’s coreutils, gnu-sed, grep, gawk and findutils formulae install the GNU replacements with a g prefix; keeping /usr/bin ahead of their gnubin folders lets the Mac still show what a vanilla Mac does.
  • POSIX.1-2024 folded in realpath, xargs -r, test -nt and set -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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.