skip to content

A shell script that works on a Linux CI runner fails on a developer's Mac at the line `sed -i 's/foo/bar/' config.txt`. Why does macOS behave differently here, and what are the portable ways to fix it?

level: middleimportance: should knowfreq 52%

answer

  1. Darwin userland descends from BSD
  2. the flag takes an argument here
  3. one invocation cannot satisfy both
  4. g-prefixed twins from Homebrew
  5. shadowing system tools helps only you

basics

~20 s

macOS ships a BSD userland, not GNU coreutils, and BSD sed requires an explicit backup-suffix argument after -i. It reads 's/foo/bar/' as that suffix and then tries to parse the filename as the script, so the command fails.

solid answer

~50 s

macOS is Darwin: its userland descends from BSD, so `/usr/bin/sed`, `date`, `stat` and `awk` are BSD tools, not the GNU ones a Linux script was written against. BSD `sed -i` takes a **mandatory** backup-suffix argument as a separate word; GNU `sed -i` takes it optionally and attached (`-i.bak`). So on macOS the command consumes `s/foo/bar/` as the backup suffix and then treats `config.txt` as the script, producing a confusing parse error. The macOS-correct form is `sed -i '' 's/foo/bar/' config.txt`, but that form is wrong on GNU, so there is no single `sed -i` invocation that works on both. Portable options: write to a temporary file and `mv` it over the original; use `perl -i -pe`, which behaves identically on both; install GNU tools with `brew install gnu-sed coreutils` and call `gsed`/`gdate` explicitly; or run the script in a Linux container so the userland matches CI.

code

bash · 7 lines
bash
# Not portable: BSD sed needs the suffix, GNU sed must not have it.
# sed -i 's/foo/bar/' config.txt      # fails on macOS
# sed -i '' 's/foo/bar/' config.txt   # fails on GNU/Linux

# Portable: edit to a temp file and move it into place.
tmp=$(mktemp)
sed 's/foo/bar/' config.txt > "$tmp" && mv "$tmp" config.txt

go deeper

for a junior

Recall that macOS command-line tools are BSD versions, so flags you learned on Linux may not exist or may need different arguments — sed's in-place flag being the classic example.

for a middle

Explain the parse: BSD sed's -i requires a separate suffix argument, so the substitution expression is eaten as the suffix and the filename is compiled as the script. Name the same trap in date and stat.

for a senior

Show the portable instinct — a temp file and mv, or a tool that behaves identically everywhere — and be able to argue why installing GNU tools over the system ones solves one machine's problem while creating the team's.

for a principal

Decide the policy: whether operational scripts must be POSIX-portable across the fleet or whether the team standardises on running them in Linux containers, and make that a stated convention rather than something each author rediscovers.

## macOS is a BSD, not a GNU system Darwin's userland comes from BSD, and Apple has never adopted GNU coreutils. That means the tools with the familiar names are different programs with different flags: - `/usr/bin/sed` is BSD sed, not GNU sed - `/bin/date` is BSD date - `/usr/bin/stat` is BSD stat - `/usr/bin/awk` is the classic one-true-awk, not gawk They share the POSIX-specified behaviour and diverge everywhere else — and "everywhere else" is exactly where scripts written on Linux live. A second reason the divergence is so wide is licensing: Apple stopped shipping GPLv3 software, which froze or removed the GNU tools that were once bundled. ## The specific sed failure GNU sed's `-i` takes an *optional* suffix, and because it is optional it must be attached: `-i` alone edits in place, `-i.bak` keeps a backup. BSD sed's `-i` takes a **required** suffix as the next argument: `-i ''` means "in place, no backup", `-i .bak` means "keep a backup". So on macOS: ```bash sed -i 's/foo/bar/' config.txt ``` is parsed as: suffix = `s/foo/bar/`, script = `config.txt`. sed then tries to compile `config.txt` as a sed program and fails with a message about an invalid command code — a message that tells you nothing about the real problem, which is why this eats an hour the first time. The macOS form is: ```bash sed -i '' 's/foo/bar/' config.txt ``` and that form is not portable back to GNU sed, where the empty string becomes the *script*. There is genuinely no single `sed -i` line that is correct on both. Other BSD-vs-GNU sed differences that bite: BSD sed does not interpret `\t` in the replacement text (it produces a literal `t`), and BSD basic regular expressions do not support GNU's `\+` and `\?` extensions. `-E` for extended regular expressions is one of the few things both accept. ## The same trap in other tools **date.** Date arithmetic is completely different. GNU: `date -d '1 day ago' +%F`. BSD: `date -v-1d +%F`. And `-r` means different things — on BSD it interprets an epoch value, on GNU it reads a file's modification time. **stat.** GNU uses `-c` with `%s` for size; BSD uses `-f` with `%z`. A script that prints a file size with `stat -c %s` fails outright on macOS. **ls.** GNU's `--color` long option does not exist; BSD uses `-G`. GNU long options in general are sparse across the BSD tools. ## The fixes, ranked **1. Avoid the non-portable feature.** In-place editing is a convenience, not a requirement: ```bash tmp=$(mktemp) sed 's/foo/bar/' config.txt > "$tmp" && mv "$tmp" config.txt ``` This is POSIX, works on every Unix, and has the pleasant side effect of not clobbering the original if sed fails. **2. Use a tool that is the same everywhere.** Perl behaves identically on macOS and Linux: ```bash perl -i -pe 's/foo/bar/' config.txt ``` macOS still bundles perl, though Apple has flagged the bundled scripting runtimes as deprecated for future removal, so a long-lived script should not lean on it forever. **3. Install the GNU tools and call them by their real names.** `brew install coreutils gnu-sed grep findutils` installs GNU versions under `g`-prefixed names — `gsed`, `gdate`, `gstat`, `ggrep`, `gfind`. Each of those formulae also ships an unprefixed directory, `$(brew --prefix <formula>)/libexec/gnubin`, that you can put on PATH to shadow the BSD versions. That last option is the tempting one and the one to think hardest about. Putting `gnubin` on PATH globally makes *your* Mac behave like Linux — and makes it unlike every colleague's Mac and unlike anything the script will run on. Scripts written on that machine acquire silent GNU dependencies that fail for everyone else. Calling `gsed` explicitly is honest; shadowing `sed` is a trap you set for your teammates. **4. Match the runtime instead of the script.** If the script only ever runs in CI on Linux, the real fix may be to run it on Linux locally too — inside a container — rather than making it bilingual. ## What the interviewer is checking Not whether you memorised `-i ''`. They want to hear that you know macOS's userland is BSD-derived, that you reach for a portable construct rather than sprinkling platform checks through a script, and that you understand why installing GNU tools over the top of the system ones is a personal convenience with a team-wide cost.

  • What does `brew install coreutils` actually give you, and why are the commands prefixed with g?
    It installs GNU coreutils as `gls`, `gdate`, `gstat`, `gcp` and so on, so they cannot shadow the BSD tools macOS relies on. The formula also ships `$(brew --prefix coreutils)/libexec/gnubin`, a directory of unprefixed names you can prepend to PATH if you deliberately want GNU behaviour by default. `gnu-sed`, `grep` and `findutils` follow the same pattern.
  • Why is putting the gnubin directory at the front of PATH a risky habit on a shared team?
    It makes one machine behave like Linux while every other Mac and every review of the script still sees BSD tools. Scripts written there quietly acquire GNU-only flags that fail for teammates and in any environment without the same PATH. Calling `gsed` by name keeps the dependency explicit and visible in the script.
  • How would you catch this class of bug before a Mac developer hits it?
    Run the scripts where they will actually run — in CI on Linux — and additionally lint them with a shell checker that flags non-POSIX usage. For scripts that genuinely must run on both, prefer POSIX constructs, and test them on macOS in the pipeline if any Mac path matters; a platform-detection branch is a last resort, not a design.

saying these in an interview costs you the question

  • Assuming macOS ships GNU coreutils under the same names
  • Believing sed -i '' is the portable form for both systems
  • Adding platform-detection branches instead of a portable construct
  • Thinking the difference is only about long option names
  • Shadowing system tools with gnubin and calling it a fix

context