Skip to content

GNU grep and BSD grep: what travels and what does not

Level: 201 · you copy grep lines between a Linux book or server and a Mac

One line: The grep that ships with macOS, BSD grep 2.6.0, calls itself "GNU compatible", and most of a Linux book's grep lines do run on it unchanged: \w, \s, \b, \<, \|, \+, \?, --include, --null, egrep. What does not travel fails in one of three ways. Loudly: -P is an invalid option. Silently: "\d" is a digit to BSD grep and the letter d to GNU grep, and neither warns. Or in the output: -r with no directory, -Z, and symlinks under -r and -R give a different list.

Measured

What travels

Every line of this script printed the same bytes with GNU grep 3.11 on Ubuntu and BSD grep 2.6.0-FreeBSD on macOS:

Verified output of gnubsd_same_sh.sh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ grep -E "[[:digit:]]+" hosts.txt
alma 9 build
ubuntu 24 box

$ grep "[0-9]\{2\}" hosts.txt
ubuntu 24 box

$ grep -E "[0-9]{2}" hosts.txt
ubuntu 24 box

$ grep -o "\w\+ box" hosts.txt
fedora box
24 box

$ grep -c "\s" hosts.txt
4

$ grep -o "\bbox\b" hosts.txt
box
box

$ grep "\<box\>" hosts.txt
fedora box
ubuntu 24 box

$ grep "alma\|ubuntu" hosts.txt
alma 9 build
ubuntu 24 box

$ grep "fed\?ora" hosts.txt
fedora box

$ grep -e "box$" -e "^alma" hosts.txt
alma 9 build
fedora box
ubuntu 24 box

$ echo aaa | grep -o "^a"
a

$ grep -r --include="*.log" darkfi logs
logs/web.log:darkfi crashed

$ grep -l --null darkfi logs/web.log logs/notes.txt | tr "\0" "|"; echo
logs/web.log|logs/notes.txt|

$ egrep "alma|ubuntu" hosts.txt
alma 9 build
ubuntu 24 box

$ fgrep "a.b" hosts.txt; echo "status $?"
status 1

$ printf "5\nd\n" | grep \d
d
  • POSIX classes and intervals[[:digit:]], \{2\} in a basic expression, {2} with -E — are in the POSIX grammar ↗, and both greps gave them the same answers. They are the safest spelling.
  • The GNU backslash extensions work in BSD grep too: \w and \s, \b and \< \> for word edges, and \|, \+ and \? inside a basic expression.
  • -e box$ -e ^alma is two patterns, each with its own anchor. It is the portable form of an alternation whose branches carry ^ or $, which box$\|^alma is not, below.
  • grep -o "^a" on aaa printed one a on both.
  • --include, --null, egrep and fgrep behave alike, and neither egrep nor fgrep printed a warning on either machine. grep -E and grep -F are the spellings POSIX ↗ specifies, and the ones to write.
  • grep \d without quotes printed d on both, for a reason that has nothing to do with grep: the shell removed the backslash, and both greps received the single letter d. The quoted "\d" is where they part.

What does not travel

Each line of this script printed something different on the two machines. The key is split on purpose:

Verified output of gnubsd_differ_sh.sh on Linux — regenerated by tools/run_examples.py, never hand-typed.

$ grep --version | head -n 1
grep (GNU grep) 3.11

$ grep -P "\d+" hosts.txt; echo "status $?"
alma 9 build
ubuntu 24 box
status 0

$ printf "5\nd\n" | grep "\d"
d

$ printf "A\nx41\n" | grep "\x41"
x41

$ grep "[[:<:]]box[[:>:]]" hosts.txt; echo "status $?"
grep: Invalid character class name
status 2

$ grep "box$\|^alma" hosts.txt
alma 9 build
fedora box
ubuntu 24 box

$ grep -cE "alma|" hosts.txt; echo "status $?"
4
status 0

$ printf "x\nax\n" | grep -E "a{,2}x"; echo "status $?"
x
ax
status 0

$ grep "[" hosts.txt; echo "status $?"
grep: Invalid regular expression
status 2

$ grep -r darkfi | sort
extra/more.txt:darkfi in extra
hosts.txt:darkfi node
logs/web.log:darkfi crashed

$ grep -R darkfi logs | sort
logs/extra_link/more.txt:darkfi in extra
logs/hosts_link.txt:darkfi node
logs/web.log:darkfi crashed

$ grep -r darkfi logs/hosts_link.txt; echo "status $?"
darkfi node
status 0

$ grep -lZ darkfi hosts.txt logs/web.log | tr "\0" "|"; echo
hosts.txt|logs/web.log|

$ grep -q darkfi hosts.txt nosuch.txt; echo "status $?"
status 0

Verified output of gnubsd_differ_sh.sh on macOS — regenerated by tools/run_examples.py, never hand-typed.

$ grep --version | head -n 1
grep (BSD grep, GNU compatible) 2.6.0-FreeBSD

$ grep -P "\d+" hosts.txt; echo "status $?"
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 ...]
status 2

$ printf "5\nd\n" | grep "\d"
5

$ printf "A\nx41\n" | grep "\x41"
A

$ grep "[[:<:]]box[[:>:]]" hosts.txt; echo "status $?"
fedora box
ubuntu 24 box
status 0

$ grep "box$\|^alma" hosts.txt
alma 9 build

$ grep -cE "alma|" hosts.txt; echo "status $?"
grep: empty (sub)expression
status 2

$ printf "x\nax\n" | grep -E "a{,2}x"; echo "status $?"
status 1

$ grep "[" hosts.txt; echo "status $?"
grep: brackets ([ ]) not balanced
status 2

$ grep -r darkfi | sort
./extra/more.txt:darkfi in extra
./hosts.txt:darkfi node
./logs/web.log:darkfi crashed

$ grep -R darkfi logs | sort
logs/web.log:darkfi crashed

$ grep -r darkfi logs/hosts_link.txt; echo "status $?"
status 1

$ grep -lZ darkfi hosts.txt logs/web.log | tr "\0" "|"; echo
hosts.txt
logs/web.log


$ grep -q darkfi hosts.txt nosuch.txt; echo "status $?"
grep: nosuch.txt: No such file or directory
status 0
The line GNU grep 3.11, Linux BSD grep 2.6.0, macOS
-P Perl-compatible regex: the lines with a digit invalid option -- P, the usage text, status 2
"\d" the letter d: it matched d a digit: it matched 5
"\x41" the letters x41 the character A
[[:<:]] and [[:>:]] an error, status 2 BSD's own word edges: both box lines
$ at the end of a branch in a basic expression an anchor: three lines an ordinary character: only alma
an empty branch after -E matches every line: 4 empty (sub)expression, status 2
{,2} after -E 0 to 2 of the atom: x and ax not an interval: no match, status 1
[ one wording of the error another, same status 2
-r with no directory searches ., names like hosts.txt searches ., names like ./hosts.txt
-R into a directory with symlinks follows both symlinks in logs follows neither
-r on a symlink named on the command line follows it does not, status 1
-lZ a NUL after each name a newline after each name: -Z is not --null here
-q with a missing second file silent, status 0 the missing-file message, status 0

Three of those rows are worth remembering over the rest:

  • "\d" fails silently. A Linux book that writes grep '\d' for a digit is wrong on Linux too, where it matches the letter d, and it is right by accident on a Mac. Neither grep said anything. [[:digit:]] or [0-9] means a digit to both, and so does \d behind -P on Linux.
  • -lZ | xargs -0 splits nothing on a Mac. GNU grep's -Z is --null; BSD grep's -Z is something else, and the names come out one per line. --null works on both, as the first script showed.
  • grep -r with no directory is not a stable format. Both searched the current directory, but a script that strips the ./ from BSD grep's names, or expects it, breaks on the other machine. Name the directory, grep -r darkfi ., and both print ./.

On a Mac

This page is the Mac's half. The portable spellings, all measured on this page or the everyday options:

Instead of Write Because
grep '\d' grep '[[:digit:]]' or grep '[0-9]' \d means a digit to one grep and d to the other
grep -P '...' grep -E with POSIX classes; or perl -ne 'print if /.../'; or fish's string match -r BSD grep has no -P
an alternation with ^ or $ in a branch grep -e 'box$' -e '^alma' BSD grep reads $ at the end of a branch as a character
grep -lZ grep -l --null BSD grep's -Z is not the NUL separator
grep -r pattern grep -r pattern . the two greps name the files differently
egrep, fgrep grep -E, grep -F the POSIX spellings

The encodings library's grep page ↗ has two more differences, both on text that is not plain ASCII: BSD grep skipping a line it cannot decode, and the "binary file matches" notice going to a different stream on each machine.

In zsh and fish

The shell does not change what grep does with a pattern, but it does change what reaches grep. In zsh, as in bash, an unquoted backslash is removed first:

Verified output of gnubsd_zsh.zsh, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ print -r -- \d "\d"
d \d

$ printf "5\nd\n" | grep \d
d

$ [[ "ubuntu 24" =~ "[[:digit:]]+" ]] && echo "$MATCH"
24

$ [[ "ubuntu 24" =~ "[0-9]+" ]] && echo "$MATCH"
24

\d unquoted arrived as d; "\d" arrived with its backslash. zsh's own =~ compared against [[:digit:]]+ and [0-9]+ and put 24 in $MATCH on both machines.

fish removes an unquoted backslash too, and it has a regex matcher of its own:

Verified output of gnubsd_fish.fish, identical on Linux and macOS — regenerated by tools/run_examples.py, never hand-typed.

$ echo \d "\d"
d \d

$ printf "5\nd\n" | grep \d
d

$ string match -r "\d+" "ubuntu 24"
24

$ string match -re "\d" < hosts.txt
alma 9 build
ubuntu 24 box

$ string match -rq "\d" -- "fedora box"; or echo "no digit"
no digit

$ perl -ne "print if /\d/" hosts.txt
alma 9 build
ubuntu 24 box

string match -r is built into fish and uses PCRE2 on both machines, so \d is a digit in it on a Mac as well: it is the -P BSD grep does not have. With -e it prints entire matching lines, as grep does; without it, only the match. perl -ne 'print if /\d/' printed the same two lines, and perl is on both machines too.

If you are coming from another library

See also