Filtering a 330 GB Gzipped CSV: A 9000x Speed-up from 23 days to 3 minutes

9000x faster, over 330 GB and 1.2 billion lines: BSD grep 23 days, mawk 3 minutes 40 seconds

I needed to filter a gzipped CSV against a list of 1122 UUIDs and write gzipped output. The file was 11 GB compressed and 330 GB uncompressed, so there was no room to stage it on disk. Everything had to stream through pipes.

I reached instinctively for zcat | grep -F -f | gzip. On macOS that grep is BSD grep, not GNU grep, and that is where the 23 days 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.

The File and the Patterns

Compressed 11 GB
Uncompressed 329,691,473,398 bytes
Lines ~1.234 billion
Mean line length 267 bytes
Patterns 1122 lowercase UUIDs, 36 chars each
Pattern alphabet -0123456789abcdef (17 distinct bytes)
Match rate 13% of lines
Output 159,814,097 lines, 44.2 GB (1.39 GB gzipped)
Platform macOS

A row looked like this:

dbe3e826-2c92-4de6-af4b-9c23594bc9a1,0b4d8db9-34f2-42e0-ad66-048963a7c42b,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",174.193.90.38,HLS,3000,98BD8DE2-2457-EC11-9820-E42AACA6A033

The IDs I needed to match were in field 1. Field 4 is a quoted user agent containing commas, which means awk -F, can't be trusted to address any column after it. I cover that under quoted fields below. It's why the pipeline below matches a byte offset instead of a field.

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

NR==FNR is the standard awk idiom for "still reading the first file". It's true only while awk is on the pattern file, so you get one block for loading and one for matching: the first slurps the patterns into the hash p, and next skips the rest of the program for those records. The trailing - makes awk read stdin as the second file. FNR==1|| keeps the header. Nothing touches disk uncompressed.

3m40s for 330 GB and 1.234 billion lines.

The Hunt

Step 1: the reflex. 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 the output file is open and its size is readable from outside. lsof -p $PID confirms which file the process is holding, and ls -l gives the size:

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

11,323,173 bytes, which told me nothing, because I had no idea whether the finished file would come to 12 MB or 12 GB. Without a denominator I couldn't say whether that was nearly done or barely started, so I let it keep running.

Step 3: get a denominator. While grep ground on, I put the same filter through ripgrep. Seven minutes, and 1.3 GB of gzipped output.

Now the 11 MB had a denominator. Against 1.3 GB, grep had managed 0.8% in four and a half hours. Why it was four orders of magnitude behind is macOS grep Has No Multi-Pattern 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. A second implementation with different semantics would tell me whether that distinction mattered on this data. BSD awk, hashing field 1: 1 hour 43 minutes. Why that was slow in turn is macOS awk Costs 2.8 µs per Record.

Step 5: same answer, still too slow. awk agreed with ripgrep, but 1h43m wasn't a number I wanted to live with for a job I'd be re-running. Chasing a faster awk produced 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, which said two things about the data: no centre ID ever appeared outside column 1, and field 1 was always an unquoted 36-byte value. That was what made the extract shippable. Compare the decompressed streams rather than the .gz files, or gzip header differences will give you a false mismatch.

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

The two progress checks agree. 0.81% at four and a half hours and 2.73% at fifteen project to 23.0 and 22.9 days. Ten and a half hours apart, within 1% of each other, so grep wasn't degrading or warming up. It held 0.17 MB/s the whole way.

Five Matchers, 9000x Apart

Effective throughput by matcher, log scale
Command Wall time Effective rate
pigz -dc > /dev/null (the floor) 2m22s 2.33 GB/s
mawk with substr 3m40s 1.50 GB/s
mawk with -F, ~6m 0.92 GB/s
rg -aF -f ~7m 0.79 GB/s
BSD awk (the macOS default) 1h43m 53 MB/s
BSD grep -F -f (the macOS default) aborted at 15h 0.17 MB/s

Against mawk's 3m40s, grep's projected 23 days is a 9000x spread.

Why the Defaults Were Slow

Three separate costs: the wrong grep, the wrong awk, and one awk expression doing more work than it needed to.

macOS grep Has No Multi-Pattern Automaton

The grep on macOS is not GNU grep:

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

The two have completely different multi-pattern implementations.

GNU grep compiles the entire pattern set into one automaton and makes a single pass. Historically that was an algorithm close to Commentz-Walter, which is Aho-Corasick plus a Boyer-Moore skip table. That code was removed in January 2017 in favour of plain Aho-Corasick, because Commentz-Walter's O(mn) worst case was reachable in practice. Either way, adding patterns doesn't multiply the work.

So why did 1122 patterns cost 23 days? Because BSD grep has no such automaton. It reads a line, then evaluates the pattern set against it. With 1122 patterns and 1.23 billion lines that's a multiplication instead of a single pass, and 0.17 MB/s is roughly what it costs.

The FreeBSD wiki, on where bsdgrep's time goes:

the main bottleneck is not in the regex(3) implementation but in grep itself

The commit that made bsdgrep the FreeBSD default calls the slowdown against gnugrep

the price to pay for fewer bugs

before pointing anyone who needs speed at ripgrep.

I did not read the source, so the per-line mechanism is inference from documented behaviour rather than something I confirmed. The 0.17 MB/s is the measured part.

If you want GNU grep on a Mac, brew install grep installs it as ggrep.

Regardless of which grep you have, two things are worth ruling out:

  • A dirty pattern file. An empty line matches every input line and disables all fast paths. CRLF endings silently append \r to every pattern. Check with grep -c '^$' ids.txt and file ids.txt.
  • A short pattern. Any implementation that skips is bounded by the shortest pattern in the set, so one 3-character entry can dominate. Check with awk '{print length}' ids.txt | sort -n | head -5. All 1122 of mine were exactly 36.

What ripgrep Does Instead

ripgrep has neither problem, though the path from -F -f to a matcher has a layer I didn't expect. It doesn't hand the literals to a dedicated multi-pattern engine. It welds them into one enormous alternation and gives that to Rust's regex crate, which rg --trace will print 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. That crate offers several strategies, and going by its published selection rules, a set of this shape qualifies for none of the quick ones:

  • A prefilter, which scans for one cheap byte and only wakes the automaton where that byte appears. It needs either three or fewer distinct first bytes across the whole set, or three bytes that appear in every pattern and are rare enough to be worth hunting for. Lowercase UUIDs start with any of 16 hex digits, and every byte they contain is a hex digit or a hyphen, so there is nothing rare to scan for.
  • Teddy, a SIMD algorithm ported from Intel's Hyperscan that compares several patterns against a block of input at once. Per the crate's design notes it only works well below roughly 100 patterns. I had 1122.
  • A full DFA, a deterministic finite automaton, where every input byte advances the machine by exactly one state via a single table lookup. It is the fastest of the automata but its transition table costs orders of magnitude more memory, so the crate only builds one when the pattern count is small.

That leaves the crate's default, a contiguous NFA: nondeterministic, so it may follow several failure transitions for a single input byte, but it 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 published selection criteria applied to my pattern set rather than a decision I watched get made.

Could the pattern set be reshaped to reach a fast path? Not usefully. Splitting 1122 patterns into twelve batches of under 100 would let Teddy engage, but that's twelve passes over 330 GB against one. The prefilter can't be helped at all, because the bytes that make UUIDs UUIDs are exactly the bytes that saturate an analytics export. A faster literal matcher wouldn't help either. The mawk version sidesteps the problem by hashing field 1 rather than scanning for literals.

So ripgrep was working without any of the accelerations, and still came in around 7 minutes. Aho-Corasick reads each byte of the input once and only once, however many patterns it is carrying. The literature calls the text being searched the haystack and each pattern a needle; the linked sources use those terms throughout.

macOS awk Costs 2.8 µs per Record

macOS ships the BWK "one true awk". I benchmarked the sample, cached, with wc -l on the end:

Elapsed time on a 200 MiB cached sample by awk implementation
Command Time Rate Per line
mawk substr 0.071s 2954 MB/s 90 ns
rg -aF -f 0.272s 771 MB/s  
BSD awk, -F, on $1 2.211s 95 MB/s 2.82 µs
BSD awk, substr 2.222s 94 MB/s 2.83 µs
BSD awk, substr, no LC_ALL=C 2.239s 94 MB/s  

That 2.8 µs is flat per-record overhead, and you pay it before your program does anything: find the newline, copy the bytes into $0, evaluate, step the loop. Over 1.234 billion lines it was the entire runtime, and no change I made inside the program moved it.

Two things that made no difference under BSD awk: field splitting (-F, and substr differed by 0.5%) and locale (LC_ALL=C versus unset differed by 0.8%). Both are real costs, but they're invisible next to 2.8 µs.

Field Splitting Costs 113 ns per Line

The BSD awk table above suggests field splitting costs nothing. It doesn't generalize. Under mawk, on the full file:

Field splitting adds 140 seconds on top of a 220 second baseline
Expression Wall time
substr($0,1,36) in p 3m40s
-F, with $1 in p ~6m

140 seconds over 1.234 billion lines, about 113 ns per line.

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 that ~250-character quoted user agent, and allocate all eight field strings, only to read the first one. substr($0,1,36) reads 36 bytes and stops. Referencing only $0 means the record is never tokenized at all.

Field splitting was 4% of BSD awk's runtime and 64% of mawk's.

Silent Correctness Traps

Two failure modes that produce plausible-looking output rather than an error.

Quoted Fields Break awk -F,

awk -F, doesn't understand quoting. One comma inside "Mozilla/5.0 (KHTML, like Gecko)" shifts every subsequent field index on that row, and different user agents shift by different amounts. You get wrong data, not an error.

Fields before the first quoted column are safe. $NF is safe if nothing after the last quoted column contains commas. Everything in between isn't.

Detect it in one pass:

awk -F, 'NF!=31{n++} END{print n+0}' file.csv

Zero means -F, is correct for the entire file, and you're done. If it isn't zero, here are the options in increasing order of correctness and cost:

Count from the right, when only one column can contain commas:

awk -F, '{print $NF, $(NF-3)}'

gawk 5.3 or newer handles this natively with --csv (or -k), which takes RFC 4180 as its spec: quoted commas, doubled quotes, and newlines inside quoted fields. It overrides FS, FPAT, FIELDWIDTHS and RS entirely, and warns if you assign to any of them:

gawk --csv '{print $18,$1,$29}'

Kernighan added the same flag to the one true awk in 2023 and gawk followed to match, so the awk already on your Mac may support it. Check with awk --version.

gawk with FPAT, if you're on gawk older than 5.3. This gives true field positions, but quoted fields arrive with their quotes intact, embedded newlines still defeat it, and it's several times slower than plain splitting:

gawk -v FPAT='([^,]*)|("([^"]|"")*")' '{print $18,$1,$29}'

A real parser, the fallback when neither awk is new enough. Expect 0.5-1M lines/sec:

python3 -c '
import csv, sys
r = csv.reader(sys.stdin); w = csv.writer(sys.stdout)
h = next(r); idx = [h.index(c) for c in ("center_id","user_id","viewing_type")]
w.writerow([h[j] for j in idx])
for row in r: w.writerow([row[j] for j in idx])
'

Looking columns up by header name also means the code survives a schema change.

The Filter Eats the Header Row

The filter discards the header along with every other non-matching line. Peel it off the front of the stream and print it before the filter runs:

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

read consumes exactly one line from the shared stdin, then the filter picks up from line 2. IFS= keeps leading and trailing whitespace in the header intact, and -r stops backslashes being interpreted as escapes.

The FNR==1|| in the mawk pipeline earlier does the same job inside awk. This form is for when the filter is grep or ripgrep and has no notion of a record number.

Where My Benchmarks Misled Me

Every projection in this post started as a two-second run on a cached 200 MiB sample. Four ways that misfired, plus the floor everything else was measured against.

Run Every Benchmark Twice

My first mawk timing was 0.483s wall / 0.088s user. The repeat was 0.071s / 0.058s. The first number was process startup and cold cache. The tell was that every other command's CPU time tracked its wall time closely and mawk's didn't.

Had I stopped at the first run, 0.483s would have put mawk behind ripgrep's 0.272s, and I'd have built the pipeline around an rg prefilter. At 0.071s mawk is faster than pigz can feed it and the prefilter is pure overhead.

Cached Samples Over-Predict by ~1.5x

Same awk, same expression, three contexts:

Throughput falls from 94.5 to 70.3 to 53.3 MB per second across three measurement contexts
Context Rate vs cached sample
Cached file, standalone 94.5 MB/s 1.00x
20 GiB through the pipeline 70.3 MB/s 1.34x slower
Full 330 GB run 53.3 MB/s 1.77x slower

A read from a pipe is bounded by the kernel's pipe buffer, and on macOS that buffer is not one size. XNU picks from a ladder of 512, 1024, 2048, 4096, 8192, 16384 and 65536 bytes. A fresh pipe starts at the bottom rung, 512 bytes, and each write that won't fit in the free space bumps it to the smallest rung that will hold the backlog plus the incoming write, capped at 65536 and at a 16 MB system-wide budget. That's choose_pipespace in bsd/kern/sys_pipe.c; the constants live in bsd/sys/pipe.h. pigz writes in large blocks, so this pipe would have reached the top rung on the first write or two. Linux is a flat 64 KiB, per pipe(7).

A read from a file already in page cache has no such bound and never blocks waiting on another process. The buffer sizes are documented; attributing the 1.34x to them is my inference and I didn't instrument it. They do rule out one alternative: the 20 GiB run showed user 5m19s / real 5m05s, one core pinned on awk with pigz barely registering, so cores weren't being contended for.

Rule of thumb: multiply a cached-sample estimate by ~1.5, and run a short in-pipeline version before committing to a multi-hour job.

time pigz -dc big.csv.gz | head -c $((20*1024**3)) | your-filter-here | pigz -p 8 > /dev/null

Check the Sample Is Representative, for the Property You Care About

My sample was the leading 200 MiB, taken with head -c. For line length it was excellent:

wc -l < /tmp/sample.bin    # divide the byte count by this

267.2 bytes/line projects 1.234 billion lines for the whole file, against the 1.2 billion I'd estimated separately.

For match rate the same sample was worthless. 7794 of its 784,857 lines matched, or 0.99%. The finished output has 159,814,097 lines against ~1.234 billion in, which is 13%. The sample was low by a factor of thirteen.

The reason is that the file is ordered by field 1, so a slice off the front covers only the centre IDs that sort earliest. Confirm it on your own data before trusting a leading slice for anything density-related:

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

A handful of distinct values means the file is sorted, and the head of it covers a narrow slice. Sample size wasn't the problem here; a 2 GiB slice off the same end would have been wrong by the same factor.

gzip -l Lies About Anything Over 4 GiB

Checking the finished output:

$ gunzip -l lc-camera_stream_views_july_2026.csv.gz
  compressed uncompressed  ratio uncompressed_name
  1390818770   1279017005  -8.8% lc-camera_stream_views_july_2026.csv

A negative ratio says compressing made the file bigger, which gzip does not do to CSV. The format stores the uncompressed size in a four-byte trailer field, so anything at or above 4 GiB is recorded modulo 2^32, and gzip -l reports that wrapped value back without comment.

The real size was 44,228,689,965 bytes, which is the reported 1,279,017,005 plus ten full wraps. That works out to 31.8:1, in line with the source file's 30:1 rather than the impossible -8.8%.

The only reliable answer is to decompress and count:

pigz -dc big.csv.gz | wc -c

At 2.33 GB/s that costs about a minute for 44 GB, and it is the same command that gives you the decompression floor.

The Decompression Floor: 2.33 GB/s

pigz -dc measured 2.33 GB/s on this file:

time pigz -dc analytics.csv.gz | wc -c
# 329691473398
# real 2m21.693s

pigz can't parallelize decompression of a single gzip stream. It was fast here because a 30:1 compression ratio decoded mostly as long back-references. -p 8 still matters on the compression side. The filter emitted 44.2 GB over the 3m40s run, so the output stage was fed at roughly 200 MB/s, well past the 30-50 MB/s a single-threaded gzip manages.

At 2.33 GB/s, ripgrep at 771 MB/s was never decompression-bound: its full-run rate of 785 MB/s matched its sample rate almost exactly. mawk at 2954 MB/s was faster than the decompressor, which made it the only tool here that was floor-bound. The 3m40s against a 2m22s floor was pipe and syscall overhead, not matching.

Other macOS Gotchas

  • BSD head -c rejects K/M/G suffixes. Use head -c $((200*1024*1024)), or dd bs=1m count=200, or install ghead from Homebrew coreutils.
  • BSD tr can read a trailing - as the start of a range. Put it first: tr -dc '\-0-9a-f'.
  • The default awk is BSD awk and the default grep is BSD grep. Install mawk or gawk, and ggrep if you need GNU grep.
  • grep --version and awk --version are worth checking first. I spent a while reasoning about grep's matching algorithm before establishing which grep I was running.

Takeaways

  1. Don't use the macOS grep -F -f for large literal sets. BSD grep has no multi-pattern automaton, so cost scales with patterns times lines. Use ripgrep, or a hash lookup if the match is field-anchored.
  2. On macOS, replace the default awk. 2.8 µs of per-record overhead, 31x mawk's.
  3. Avoid field splitting when a byte offset will do, especially with a long quoted column in the row.
  4. Measure the decompression floor first. pigz -dc hit 2.33 GB/s here, faster than every matcher except mawk.
  5. Repeat every benchmark, and multiply cached-sample numbers by ~1.5 before projecting a long run.
  6. awk -F, is wrong on quoted CSV. Check NF before trusting it.

References

Algorithms:

  • 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 pattern count drives the choice

Tools:


Previous: Review: Brief: Make a Bigger Impact by Saying Less

links