Filtering a 330 GB Gzipped CSV: A 10,000x Speed-up from a Month to 3 Minutes

10,000x faster, over 330 GB and 1.27 billion lines: BSD grep about a month, mawk 3 minutes 40 seconds

I needed to filter a gzipped CSV against a list of 1122 universally unique identifiers (UUIDs) and write gzipped output. The file was 10.3 GB compressed and 330 GB uncompressed, and the machine had less free disk than that, so there was nowhere to unpack it. Everything had to stream from one process to the next.

I reached instinctively for pigz -dc | grep -F -f | pigz -p 8. On macOS that grep is BSD grep rather than GNU grep, and that is where the month came from. The best version I was able to come up with took 3 minutes 40 seconds. Getting from one to the other took most of a day, and the extract was going to a customer, so a fast answer on its own was never going to be enough.

This is the long version, for whoever wants the measurements rather than the answer: every timing, the benchmarks that misled me, a projection I got wrong twice, and the parts I still can't account for. The answer on its own, with the cost rule and the three tools that beat the default, is macOS grep -f Is a Nested Loop Join.

The Goal: 160 Million Rows Out of 1.27 Billion, Without Unpacking Anything

A customer wanted the rows belonging to their own 1122 centers out of a month of viewing analytics. The file is a comma-separated values (CSV) export, one row per stream view.

Compressed 10.3 GB (10,273,383,379 bytes)
Uncompressed 330 GB (329,691,473,398 bytes)
Lines 1.27 billion (1,272,998,467)
Mean line length 259 bytes
Patterns 1122 lowercase UUIDs, 36 characters each
Pattern alphabet -0123456789abcdef (17 distinct bytes)
Centers 1122 wanted, 3555 present in the file
Match rate 12.6% of rows
Output 160 million rows (159,814,096), 44.2 GB (1.39 GB gzipped)
Machine Apple M5, 4 performance + 6 efficiency cores, 32 GiB RAM, Apple File System on the internal SSD, macOS 26.5.2

Eight columns:

center_id,camera_id,timestamp,agent,ip_address,resource_type,view_duration,user_id

0f8a3c21-4b7e-4d19-9c02-6a1e5f3b8d47,7d2e9a44-1c58-42f0-b3a6-90e4c7182b5d,2026-07-31 13:47:16.765,"Mozilla/5.0 (Linux; Android 16; SM-S911U) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36",203.0.113.42,HLS,3000,9C4B7E21-3A8D-4F62-B105-72E9D4A6C830

The IDs I needed to match are in center_id, the first field. Field 4, agent, is a quoted user-agent string with commas inside it, so splitting that row on commas gives nine pieces rather than eight, and every column after the fourth lands somewhere you didn't intend. I come back to that under quoted fields below, and it's why the pipeline further down matches on a byte offset instead of naming a field.

Two of the tools below, awk and grep, behave nothing alike across implementations, and most of this post is about that difference. Versions as measured:

/usr/bin/awk (one true awk) 20200816
/usr/bin/grep (BSD) 2.6.0-FreeBSD
mawk 1.3.4 20260302
gawk 5.4.1
ripgrep 15.2.0
pigz 2.8
python3 3.14.7

tl;dr: The Pipeline That Worked

pigz -dc analytics.csv.gz \
  | LC_ALL=C mawk 'NR==FNR{p[$0];next} FNR==1||(substr($0,1,36) in p)' ids.txt - \
  | pigz -p 8 > filtered.csv.gz

If you haven't come across NR==FNR before, it's the standard awk idiom for "still reading the first file". NR counts records overall while FNR counts them within the current file, so the two are only equal while awk is working through the first one. That gives you one block for loading the patterns and a second for matching against them: the first slurps them into the hash p, and next skips the rest of the program so those lines never reach the matching block. The trailing - tells awk to read stdin as the second file, and FNR==1|| keeps the header row. Nothing touches disk uncompressed.

3m40s for 330 GB and 1.27 billion lines.

If you'll run this more than once, a database wins: 4m26s to load, and then every later extract is a query rather than another pass over 330 GB, though not a free one: an extract in the file's own row order costs 2m16s on top, because IN compiles to a join and joins don't preserve order. Getting there took a day, a filled disk, and a bug report: Filtering a 330 GB CSV in DuckDB.

The Hunt

Step 1: what I'd typically use. pigz -dc | grep -F -f | pigz -p 8 went in the terminal and I moved on to something else.

Step 2: is it doing anything? About four and a half hours in I wanted a progress figure. grep has no --progress, but lsof -p $PID confirms which file the process is holding and ls -l gives its size:

PID=$(pgrep -f 'grep -F -f' | head -1)
sudo lsof -p $PID
ls -l filtered.csv.gz

That came back as 11,323,173 bytes. With no idea whether the finished file would come to 12 MB or 12 GB, I couldn't tell nearly done from barely started, so I let it keep running.

Step 3: get a denominator. While grep ground on, I put the same filter through ripgrep. It finished in seven minutes and produced 1.39 GB of gzipped output.

So the 11 MB finally meant something. Against a finished size of 1,390,818,770 bytes, grep had produced 0.8% of the output in four and a half hours. Why it was so much slower than ripgrep is grep automaton below.

Step 4: can I trust the fast answer? The extract was going to a customer, so "ripgrep said so" wasn't good enough. ripgrep had matched the UUIDs anywhere on the line; what I wanted was an exact match on field 1. If a second implementation with different semantics produced the same bytes, the distinction didn't matter on this data. BSD awk, hashing field 1: 1 hour 43 minutes. Why that was slow in turn is awk overhead.

Step 5: same answer, still too slow. awk's output matched ripgrep's, but 1h43m wasn't a number I wanted to live with for a job I'd be re-running. Looking for a faster awk turned up mawk at 3m40s, and with it a third independent pass over the data.

Comparing the three outputs costs one command:

for f in out.rg.gz out.awk.gz out.mawk.gz; do
  pigz -dc "$f" | md5sum | sed "s|$|  $f|"
done

All three digests matched. Substring-anywhere, field-1-exact, and first-36-bytes had produced byte-for-byte the same 44 GB, so no wanted ID turned up elsewhere on a row that field 1 hadn't already selected, and on every row that was selected field 1 was an unquoted 36-byte value. That made the extract shippable. Compare the decompressed streams rather than the .gz files, or gzip header differences will give you a false mismatch. md5sum there is Homebrew coreutils; stock macOS has md5.

Step 6: kill grep. At fifteen hours it had written 38,024,713 bytes, 2.73% of the finished 1,390,818,770.

Output bytes are not a stand-in for input consumed unless matches are spread evenly through the file, and a leading slice shows they are not. The real positions came later, out of the file loaded into DuckDB: I cut the finished output at 11,323,173 and 38,024,713 bytes to get the rows grep had written by each check, and looked up where those rows sit in the file. grep had reached 0.70% and 2.16% of the input, which projects to 27 and 29 days, and the same grep run directly against 8 MiB of the same data gives 0.15 MB/s and 25 days. Call it a month, at 0.13 to 0.14 MB/s. That is a projection and not a measurement: I killed the run at fifteen hours with 2% of the input read, so nobody has watched this finish.

Six Matchers, 10,000x Apart

Effective throughput by matcher, log scale

The top bar is the floor: the fastest this pipeline can possibly run, because the 330 GB has to be decompressed whatever the filter does. Before optimizing anything else I measured that on its own:

time pigz -dc analytics.csv.gz | wc -c
# 329691473398
# real 1m44s      median of three runs, 103.8s to 105.2s

Purging the page cache first changes nothing, which is worth knowing before you go looking for a disk problem you don't have: the file has to be inflated either way, and that is where the time goes. pigz cannot parallelize decompression of a single gzip stream and does not need to here, because a 32:1 ratio decodes mostly as long back-references, costing about what a memcpy costs. The -p 8 is there for the compression side instead: the filter emitted 44.2 GB over the 3m40s run, roughly 200 MB/s, which is about what one gzip thread manages on this data, so it would have been the bottleneck rather than a background cost. The DuckDB post measures single-threaded compression on the same 44.2 GB: 2m57s longer.

ripgrep at 807 MB/s was never decompression-bound, and its full-run rate of 775 MB/s matched its sample rate to within 4%. mawk runs at 2.95 GB/s on a cached sample against the decompressor's 3.16 GB/s, close enough that neither is obviously the bottleneck. The pipeline that puts them together runs at 1501 MB/s, half either figure, and I did not isolate where the other half goes.

Against mawk's 3m40s, grep's projected 1 month is 10,000x slower. Every rate here is this machine, this file, and this one month of data. Compare them with each other rather than with numbers from yours.

Why the Defaults Were Slow

Three things were slow: the grep, the awk, and one awk expression. There is a note on ripgrep between the first two.

1. The macOS grep Has No Multi-Pattern Automaton

The first thing to check, and the thing I checked far too late, is that the grep on macOS is not GNU grep:

$ grep --version
grep (BSD grep, GNU compatible) 2.6.0-FreeBSD

Nearly everything in this post follows from that, because the two have different multi-pattern implementations.

GNU grep compiles the entire pattern set into one automaton and makes a single pass, and BSD grep has no such automaton. That is the FreeBSD maintainers' own description of their own code. I measured the actual behavior.

So why would 1122 patterns cost a month? Because without one, grep reads a line and then evaluates the pattern set against it a pattern at a time, so with 1.27 billion lines that's a multiplication instead of a single pass.

The multiplication is measurable. Three pattern counts against three file sizes, each cell the mean of two runs:

Patterns 8 MiB 32 MiB 128 MiB
10 0.58s 2.14s 8.70s
100 5.12s 20.62s 82.20s
1000 50.47s 206.44s 835.69s

Four times the bytes costs four times the time, and ten times the patterns costs nine to ten times the time, so what you pay for is the product: about 6.5 milliseconds per pattern per mebibyte. Multiply your pattern count by your file size in mebibytes and you have the time: 1,500 pattern-mebibytes is ten seconds and 9,000 is a minute, whatever combination gets you there.

That is what a run costs when nothing matches, which makes it a ceiling. A line that matches lets grep stop trying the rest of the patterns on it, and a run where 99.9% of lines matched came in 27% under.

Against GNU grep, the honest comparison is a cell where neither tool is near its own floor: over 128 MiB with a thousand patterns, BSD grep takes 13m56s and GNU grep 0.401s, 2084x faster. ripgrep takes 0.171s over that same 128 MiB with a thousand patterns, 4887x faster, which is the number on the short version's lead image. Both are five-run medians, measured the way BSD grep's 835.69s was. Pattern scaling separates the two greps the same way: on that same 128 MiB, a hundred times the patterns costs BSD grep 96 times the time and GNU grep 3.2.

Locale is not the explanation either: LC_ALL=C moves BSD grep's 56.2s to 54.8s, a difference inside the run-to-run spread. If you want GNU grep on a Mac, brew install grep installs it as ggrep, and that is where the 2084x speed-up is. Anchoring every pattern to ^uuid, is worth a further 2.31x against its own unanchored run over the full file at its 12.6% match rate, and 6.38x at a 1.5% match rate: the sparser the matches, the bigger the gain. It needs -E, because ^ is a literal caret under -F, and unanchored -E costs nothing by itself, within 1% for both greps, so the anchor is the whole effect.

The commit that made BSD grep the FreeBSD default, in December 2020, calls the slowdown against gnugrep

the price to pay for fewer bugs

before pointing anyone who needs speed at ripgrep or the_silver_searcher.

I have not read either grep's source.

What ripgrep Does Instead

ripgrep has an automaton and doesn't pay for that multiplication. One flag is important: the -a in rg -aF -f tells it to treat the input as text, and without it a stray byte sequence in the user-agent column can make ripgrep decide the stream is binary and stop early. A file that is quietly short is a worse outcome than a run that is slow.

None of ripgrep's own accelerations applied to a set of 1122 UUIDs, and it still came in around 7 minutes, because Aho-Corasick reads each byte of the input once and only once however many patterns it happens to be carrying. Telling it where to look barely helps either: anchoring every pattern to ^uuid,, so that only byte 1 is tested, is worth 1.06x over the full file, and between 1.06x and 1.08x at every match rate from 1.5% to 12.6%. Every anchored figure in this post comes from rounds that re-measured the unanchored baseline beside it. There is also a layer between -F -f and the matcher that I hadn't expected.

Why did none of ripgrep's fast paths engage on 1122 UUIDs?

ripgrep welds your literals into one enormous alternation and gives the result to Rust's regex crate, which rg --trace will print straight back at you:

grep_regex::config: assembling HIR from 1122 fixed string literals
grep_regex::matcher: final regex: "(?:7faedcb6-90c3-4e9a-9c6f-e2fbf0b998e7)|(?:5fe23ef0-ef4b-...)|..."

The regex engine then decides for itself how to search for 1122 alternated literals, and one of the things it can reach for is aho-corasick, a Rust library by the same author. Going by the crate's published selection rules, a set built like mine qualifies for none of its quick strategies:

  • A prefilter, which scans for one cheap byte and wakes the automaton only where it appears: needs three or fewer distinct first bytes across the set, or three bytes common to every pattern and rare in the haystack. Lowercase UUIDs start with any of sixteen hex digits and contain nothing but hex and hyphens, so there is nothing rare to scan for.
  • Teddy, single instruction, multiple data (SIMD), ported from Intel's Hyperscan, comparing several patterns against a block of input at once: works well below roughly 100 patterns, per the crate's design notes. I had 1122.
  • A full deterministic finite automaton (DFA), where each input byte advances the machine one state by one table lookup. Fastest of the automata, and its transition table costs orders of magnitude more memory, so the crate builds one only for small sets.

That leaves the crate's default, a contiguous nondeterministic finite automaton (NFA), which follows several failure transitions for a single input byte where a DFA follows one, but packs all of them into one allocation and gets close to DFA speed for a fraction of the memory.

--trace shows the regex being assembled. It does not print which matcher the engine settled on, so the list above is the crate's published selection rules applied to my pattern set.

Rearranging the pattern set doesn't reach a fast path either. Splitting 1122 patterns into twelve batches of under 100 would let Teddy engage, but that's twelve passes over 330 GB against one. Nothing can be done about the prefilter either, for the reason in the first bullet: a UUID is made of the same bytes as most of the rest of the file. A faster literal matcher wouldn't help. The mawk version sidesteps the problem entirely by hashing the first 36 bytes of each line, where the identifier sits, instead of scanning the whole line for literals.

2. The macOS awk Costs 3,200 ns per Record

The awk was next. macOS ships Brian Kernighan's "one true awk", so I benchmarked it against the sample, cached, with wc -l on the end:

Median elapsed time on a 200 MiB cached sample by awk implementation, three runs each and six for mawk

Each bar is a median of three runs, six for mawk. That 3,200 ns is the cost per record on this file's 259-byte lines, nine tenths of it paid before the program body runs at all:

  • find the newline,
  • copy the bytes into $0, and
  • step the loop.

My first mawk timing was 6.8x the repeat, 0.483s wall against 0.088s of user time where the repeat was 0.071s / 0.058s. The ratio is the tell: a single-threaded program that is not getting the CPU shows a wall time far above its user time, where one that is merely slow shows the two close together. Had I taken 0.483s at face value, mawk would have looked slower than ripgrep's 0.260s and I would have built the pipeline around an rg prefilter it does not need.

On Apple silicon you cannot pin a thread to the performance or the efficiency cores, so a starved run is something you detect afterwards rather than prevent. That is why every timing here is a median of repeated runs with user time recorded beside the wall clock.

A bare record loop, END{print NR} with no program body at all, takes 2.256s against the 2.501s above, so about 90% of BSD awk's time on this sample goes on the loop rather than on anything I wrote. Under mawk the same loop is 0.032s of 0.071s, which is why replacing the awk is the whole fix.

Neither field splitting (1.6%) nor the locale specification (4.5%) made any significant difference here, but LC_ALL=C, which has no impact on mawk, has a significant impact on other tools. Outside the C locale these tools treat the input as characters rather than bytes, so each one has to be decoded from its multibyte form before it can be compared, and ranges and case folding follow the locale's rules rather than the byte values. LC_ALL=C says to treat bytes as bytes. The same program under gawk takes 0.314s without the C locale and 0.195s with it, a 1.6x speed-up. If your data has multibyte characters in it, it is not safe.

Field splitting is not free, and the next section measures it at 81 ns a line, but BSD awk spends 3,200 ns per record, forty times as much, so the splitting is not what makes BSD awk slow.

3. Field Splitting Costs 81 ns per Line

The chart above suggests field splitting costs nothing, but that doesn't generalize. Under mawk, on the full file:

Field splitting adds 103 seconds on top of a 220 second baseline

103 seconds over 1.27 billion lines is 81 ns a line. The two outputs are byte-identical, so on this file the two expressions select the same rows, which is one more confirmation that field 1 is always a clean 36-byte UUID.

Why would naming a field cost anything, when the record is already in memory? Because awk splits fields lazily, on first reference. The moment you touch $1, awk has to scan the entire record, including the 101-character quoted user agent, and allocate every field string the row splits into, only to read the first one. By contrast substr($0,1,36) reads 36 bytes and stops, and because the program never mentions a numbered field, the record is never tokenized at all.

So the same 81 ns is 2.5% of BSD awk's 3,200 ns per record, lost in its noise, and 47% of mawk's runtime.

Wrong Answers That Come Back Without Errors

1. Quoted Fields Break awk -F, Silently

awk -F, doesn't understand quoting, so one comma inside "Mozilla/5.0 (KHTML, like Gecko)" shifts every field index after it on that row, and since different user agents carry different numbers of commas, different rows shift by different amounts. You get wrong data rather than a parse error. That is why the pipeline matches on substr($0,1,36): center_id is field 1, ahead of the first quoted column, so it never touches the problem. Anything between the first quoted column and the last is unsafe; $NF is safe as long as nothing after the last quoted column contains a comma.

If you do need a column that sits past a quoted one, gawk 5.3 or newer with --csv is the answer: brew install gawk, Request for Comments (RFC) 4180 as its spec, and 3.3x the cost of a plain -F, split for a correct answer. mawk cannot help at all, having neither FPAT nor a CSV mode in any release.

Does my file actually have quoted commas? If so, what solutions work?

One pass tells you:

pigz -dc analytics.csv.gz | awk -F, 'NF!=8{n++} END{print n+0}'

Zero means every row splits into exactly eight pieces, which on this schema means -F, is safe. If it isn't zero, mawk cannot help: no FPAT, no CSV mode, and none in any release of it, so a newer build is not the fix. The options that do work, timed on 2,000,000 rows of the same layout, extracting a column that sits after the quoted user agent:

Method Time Rate Full file Correct
mawk -F, 0.341s 1531 MB/s 3.6 min no, silently
gawk --csv 1.116s 468 MB/s 11.7 min yes
python3 csv 2.323s 225 MB/s 24.4 min yes
gawk with FPAT 49.251s 10.6 MB/s 518 min yes

mawk -F, returned HLS where the right answer is 3000, the column one place to the left, with no error and no warning.

  • gawk --csv, the answer for almost everyone. Quoted commas, doubled quotes and newlines inside fields all handled. Kernighan added the same flag to the one true awk in 2023, so a recent enough /usr/bin/awk has it, but the 20200816 build macOS ships predates it.
  • python3 csv, if the awk you have is older. Half the speed of --csv, and looking columns up by header name still works when the schema is reordered.
  • gawk FPAT, only below gawk 5.3. Quoted fields arrive with their quotes still on, embedded newlines defeat it, and it is 44x slower than --csv: twelve minutes against most of a working day, across the full file.
  • Counting from the right costs nothing extra and works when only one column can contain commas, since $NF and its neighbours index from the end of the record.

2. The Filter Eats the Header Row

The header row matches none of the patterns, so every filter here throws it away along with the rest of the non-matching lines.

mawk can put it back on its own, which is what the FNR==1|| in the pipeline at the top is doing: awk numbers its records, so line 1 is identifiable from inside the program.

grep and ripgrep have no record number and no way to say "keep the first one", so the shell has to peel the line off the front of the stream before the filter ever sees it:

pigz -dc analytics.csv.gz \
  | { IFS= read -r h; printf '%s\n' "$h"; grep -F -f ids.txt; } \
  | pigz -p 8 > out.gz

pigz -dc analytics.csv.gz \
  | { IFS= read -r h; printf '%s\n' "$h"; rg -aF -f ids.txt; } \
  | pigz -p 8 > out.gz

read takes exactly one line from the shared stdin, and the filter picks up from line 2. The IFS= keeps the header's leading and trailing whitespace from being stripped, and the -r stops backslashes being read as escapes.

3. A Newline Inside a Data Field Splits One Record in Two

This one is about the data file, not the pattern file. RFC 4180 allows a newline inside a quoted field, and mawk, grep and ripgrep all read one line at a time. Where the data contains such a newline, one record arrives as two lines: the second begins in the middle of a record, so substr($0,1,36) reads whatever happens to sit there, and an identifier split across the break matches nothing at all. None of the three reports it.

The analytics file has no such newlines, which is what makes the byte offset safe on it. Counting the same file two ways tells you: wc -l counts lines and gives 1,272,998,467, and DuckDB's CSV reader, which understands quoting, counted 1,272,998,467 records when it loaded the file for the DuckDB post. Equal line and record counts mean no field contains a newline.

4. The Pattern File Matches Everything, or Nothing

Two things to check in ids.txt whichever grep you have. Both fail silently, and in opposite directions.

One empty line in the pattern file matches every input line, so the filter returns the whole input instead of your rows. The tell runs the wrong way: the run gets faster, because an empty pattern matches before any comparison work happens and the job stops filtering and starts copying. BSD grep drops from 10.65s to 0.01s on a 2 MiB slice with 1122 patterns, GNU grep from 0.64s to 0.12s over 200 MiB, ripgrep from 0.27s to 0.09s. More empty lines change nothing and neither does where they sit, so one stray newline at the end of ids.txt does it. mawk is immune, because an empty string is never equal to a 36-byte prefix.

grep -c '^$' ids.txt      # want 0

The wrong line ending breaks it the other way. These are Unix tools, so a line ends at a line feed (LF) and everything in front of it is the pattern. A pattern file saved on Windows ends each line with a carriage return and a line feed (CRLF), so every pattern picks up a trailing carriage return that the data does not have, and nothing matches.

BSD grep returns nothing with exit status 1, and takes longer than a non-CRLF run, 14.30s vs. 10.65s, because a line that matches lets it break out of the pattern loop and a line that cannot match never does. mawk writes your header row and nothing else. ripgrep strips the carriage return and is unaffected.

The old Mac convention of a carriage return with no line feed (CR) fails worse, and it takes ripgrep down with the others: there are no line feeds at all, so the whole file reads as one enormous pattern that matches nothing. wc -l says 0 lines, which is the tell.

file ids.txt              # want "ASCII text", not "with CRLF line terminators"
wc -l < ids.txt           # want your pattern count, not 0

tr -d '\r'   < ids.txt > ids.unix.txt   # CRLF
tr '\r' '\n' < ids.txt > ids.unix.txt   # CR only

5. A Leading Slice Is Accurate for Size and Worthless for Density or Timing

My sample was the leading 200 MiB, taken with head -c. For line length it was fine: 267.2 bytes a line against the file's real 259.0, and a projected 1.234 billion lines against 1,272,998,467, both close enough to plan with.

For match rate it was worthless. 7794 of its 784,857 lines matched, 0.99%, where the finished job matched 12.6%. The file is written in runs of one center at a time, so a slice off the front covers only the centers that happen to start it. One command tells you before you trust it for anything density-related:

pigz -dc analytics.csv.gz | head -c $((200*1024*1024)) | cut -d, -f1 | sort -u | wc -l

Here that is 9 against the 3555 centers in the whole file. A handful of distinct values means the file is grouped by that column, and every projection made from the head of it will be wrong in the direction of whatever the head happens to contain. It was also too small: by the first 1% of the file the cumulative match rate is 13.9%, overshooting the real 12.6% rather than undershooting it.

The same slice is worthless for timing anything whose cost moves with the match rate. Anchored GNU grep takes 2m54s over the full file at a 1.5% match rate and 7m27s at 12.6%, a 2.56x rise. The same two runs over the leading 128 MiB take 0.075s and 0.077s, flat to within 3%. Measure there and you would never know the cost moves. I cut four 128 MiB windows, one from the head and the others a quarter, half and three quarters of the way in, and anchoring made GNU grep about 4.8x faster on every one of them at the job's match rate; over the whole file it is 2.31x.

6. gzip -l Misreports Any Output of 4 GiB or More

The gzip trailer holds the uncompressed size in four bytes, so anything at or above 4 GiB comes back modulo 2^32 with no warning. This file's real 44.2 GB is reported as a ratio of -8.8%, which would mean compressing made it bigger. pigz -l at least refuses to guess, printing unk where the reduction goes when the stored length and the compressed size cannot both be true. Only pigz -dc filtered.csv.gz | wc -c actually answers, in about fourteen seconds.

Other macOS Gotchas

  • head -c: no K/M/G suffixes. Use $((200*1024*1024)), dd bs=1m count=200, or Homebrew's ghead.
  • tr: a trailing - reads as the start of a range. Put it first, tr -dc '\-0-9a-f'.
  • Defaults are BSD awk and BSD grep. Install mawk or gawk, and ggrep for GNU grep.
  • grep --version and awk --version first.

Takeaways

  1. Never grep -F -f a large literal set on macOS: no multi-pattern automaton, 6.5 ms per pattern per mebibyte. rg -aF -f is 4887x faster over 128 MiB with a thousand patterns and changes nothing else; ggrep is 2084x faster and keeps the flags you know; a hash beats both when the match sits at a known position, by 1.83x over anchored ripgrep and 2.04x over anchored GNU grep on this job.
  2. Replace the default awk: 3,200 ns per record, 35x slower than mawk.
  3. Byte offset over field splitting, especially past a long quoted column. It is positional, so a new column at the front breaks it silently: check the schema each time.
  4. Measure the decompression floor first: pigz -dc at 3164 MB/s here. Only mawk comes near it, and only on a 200 MiB sample rather than over the whole file.
  5. Repeat every benchmark; never project a long run from a short one. Against their full-file rates, ripgrep's 200 MiB sample landed within 4%, BSD awk's was 1.6x optimistic and mawk's 2.0x. There is no constant to multiply by.
  6. awk -F, is wrong on quoted CSV: check NF first.

Follow-on: DuckDB Ran Out of Disk, Then Matched This Pipeline

Once the extract was out the door I loaded the whole file into DuckDB, and getting there was the problem: my first attempt filled 126.1 GiB of disk and died, and working out why took a day, four wrong answers, and a bug report.

What went wrong, and what the right load looks like, is in Filtering a 330 GB CSV in DuckDB. The analysis the extract was for, and what it took to direct an AI agent through it, is in Pitfalls Directing an AI Agent.

References

Algorithms. These call the text being searched the haystack and each pattern a needle:

  • Commentz-Walter - Aho-Corasick merged with Boyer-Moore skipping, O(mn) worst case. History rather than current practice: GNU grep used something close to it until the code was removed in 2017.
  • Aho-Corasick - the trie plus failure transitions that reads each input byte once and only once
  • Boyer-Moore - the skip table and shift heuristics that Commentz-Walter borrowed
  • Teddy - SIMD packed substring matching, ported out of Intel's Hyperscan
  • aho-corasick DESIGN.md - how the crate picks between noncontiguous NFA, contiguous NFA, DFA, and Teddy, and why the choice depends on pattern count

Tools:

links

social