There is a jail on my server whose entire purpose is to be abused with stopwatches, and yesterday evening it held a tar archive of 32,708,608 bytes: every header under /usr/include and every configuration file in /etc, which is about as ordinary a pile of text as a FreeBSD machine produces. Through zstd -1 it went in fifty milliseconds. Through zstd -19 it took seven and a half seconds, a hundred and forty-eight times longer, and the file that came out was a third smaller.
Then I unpacked both of them. Twenty milliseconds each.
I ran the whole matrix again, because a flat column like that usually means the stopwatch has broken. It had not. Every level in the range came back at the same speed, give or take the noise of a busy host, and that is the measurement this piece is about. Most data in a computer's life gets written once and read for years afterwards, and this dial charges only at the writing end.
One dial, nineteen positions
Yann Collet released zstd 1.0 on 31 August 2016, some years after he had written LZ4, and the two programs answer the same question from opposite ends. LZ4 goes as fast as the hardware will allow and takes whatever ratio falls out. zstd asks how much time you have.
The command line takes -1 through -19. Behind --ultra sit three more levels, and --fast accepts negative numbers for anybody in a genuine hurry. The dial did this to my pile of headers, measured against the two other compressors in the base system:
32,708,608 bytes of headers and configuration files
size ratio pack unpack
zstd -1 6,454,383 5.07x 0.05 s 0.02 s
zstd -3 5,798,341 5.64x 0.08 s 0.02 s
zstd -9 4,951,920 6.61x 0.32 s 0.02 s
zstd -19 4,372,994 7.48x 7.39 s 0.02 s
gzip -6 6,063,633 5.39x 0.76 s 0.06 s
gzip -9 6,013,732 5.44x 0.87 s 0.06 s
xz -6 4,267,800 7.66x 7.71 s 0.19 s
xz -9 4,222,380 7.75x 9.43 s 0.20 s
The row worth reading twice is the second one. zstd -3 is what you get when you type zstd with no arguments at all, and it produced a smaller file than gzip -9 in under a tenth of the time: 5,798,341 bytes against 6,013,732, at 0.08 seconds against 0.87. At the far end xz -9 won the ratio, as it was always going to, and charged 9.43 seconds for the privilege.
What comes back
The reason for that flat unpacking column sits in the specification. RFC 8878 describes a decoder, and it describes exactly one: a stream of literals and back-references wrapped in entropy coding, with a header that says how big a window the reader needs. The compression level appears nowhere in the document, because it never leaves the encoder. All nineteen settings emit the same kind of file. What separates them is how long the compressor is willing to hunt for a better match before it gives up and writes the sequence down.
Against zstd's 0.02 seconds, gzip wanted 0.06 and xz wanted 0.19. Three times and ten times, and the ten is the one that matters, because xz is what a great many projects reach for when they want their release tarballs small.
Memory told the same story from a different angle. Unpacking peaked at 12,396 KB for zstd and 35,356 KB for xz. On a build server nobody would notice. Inside a filesystem that decompresses a block whilst a process waits on the read, the gap between twelve megabytes per stream and thirty-five decides whether an algorithm gets in at all, and that becomes relevant a few paragraphs from here.
The other corpus
Text flatters compressors. So I built a second archive out of /usr/bin, 22,328,320 bytes of stripped ELF binaries, which compress badly and honestly:
22,328,320 bytes of /usr/bin
size ratio pack unpack
zstd -3 8,276,583 2.70x 0.12 s 0.03 s
zstd -19 6,401,566 3.49x 7.41 s 0.03 s
gzip -6 8,719,822 2.56x 0.67 s 0.07 s
gzip -9 8,665,817 2.58x 3.77 s 0.09 s
xz -6 5,937,088 3.76x 8.12 s 0.26 s
xz -9 5,841,040 3.82x 7.39 s 0.20 s
Here the dial runs out of road. xz -9 and zstd -19 spent the same seven and a half seconds, and xz came back 560,526 bytes smaller, which is 8.8 per cent. Anyone publishing a release tarball that will be mirrored across the world and unpacked once should still be publishing xz, and zstd has never pretended otherwise.
The gzip rows carry a small lesson of their own. Going from -6 to -9 cost 5.6 times the compression time and bought 0.6 per cent of size. Above -6 there is very little road left in that tool, which is why thirty years of shell scripts never bothered going there.
A dictionary for the small ones
A compressor opening a small file has an empty window and nothing to refer back to, which is why a thousand tiny records compress so much worse than the same bytes in one archive. zstd ships an answer to that, and I expected it to be a gimmick.
I took 300 header files of under 16 KB each, 1,624 KB of source altogether, and compressed them individually at level 3. Total: 629,425 bytes. Then I let zstd --train build a dictionary out of the same sort of material, 112,640 bytes of it, and ran the same 300 files again with -D. Total: 337,800 bytes. Forty-six per cent gone from files that a conventional compressor can barely touch.
I trained the dictionary on the corpus I then compressed, which flatters the number considerably. It is also precisely what a message queue or a column store would do with its own historical data, so that flattery is exactly the case the feature was built for. The dictionary is built once and referenced by every stream afterwards.
It came with the operating system
On this machine zstd is /usr/bin/zstd, 137,416 bytes, sitting in the base system a few inodes away from gzip. Nothing was installed and no package manager was consulted. FreeBSD brought it into base for 13.0, and ZFS is the reason it went there at all: OpenZFS 2.0 added zstd as a dataset compression algorithm, and a filesystem cannot ask a package manager for its decompressor halfway through booting.
From there it spread outwards in a way that says something about the shape of the thing. The Linux kernel carries the decoder in its own tree and can compress its image with it. Ubuntu moved its package payloads onto it, and RPM has shipped zstd payloads since Fedora 31. Then in March 2024 Chrome 123 began advertising Content-Encoding: zstd, with Firefox 126 following that May. Both of them do it over HTTPS only, which tells you what the past decade taught everybody about transparent proxies and the boxes in the middle of the network.
Facebook paid for the original work. Since February 2021 the format has been RFC 8878, with the window sizing rules for HTTP nailed down separately in RFC 9659 four years later, so the format no longer depends on Facebook remaining interested.
The limit
Four honest deductions, and the first one undercuts the obvious argument.
The library is three times the size of the one it displaces. zstd's library is 73,763 lines of C across 85 files. zlib is 22,732. liblzma, the engine inside xz, is 34,947 across 145 files. A case for zstd built on line counts would lose, and it should. The reduction it offers happens at the other end of the wire, where one format and one number replace a choice between two tools and a folk rule about which to use.
xz still wins on ratio and always will. The binary corpus above is the honest version of that: equal time, and 8.8 per cent more compression for the other side. The gap widens further on data that rewards a long window. For the archive that is built once and downloaded a million times, ratio is the only column anyone should be reading.
Three CVEs since 2016, all in the command line wrapper. CVE-2021-24031 and CVE-2021-24032 describe a window during which a file being compressed sits on disk with the wrong permissions. CVE-2022-4899 is a buffer overrun reachable by handing the tool an empty string as an argument, fixed after 1.4.10. Nothing so far in the frame decoder, which is the code that runs inside kernels and browsers and is therefore the part I would actually lose sleep over. A clean record there is encouraging without being a guarantee.
Level 19 is expensive, and twenty cores barely helped. Peak memory at -19 was 123,480 KB against 4,324 KB for gzip -9, which is what a deep match search costs and why the default sits at 3. Threading disappointed me too: -T0 across twenty cores finished in 8.49 seconds against 9.39 for a single thread, a gain of under ten per cent, because a 32 MB input gives the block splitter almost nothing to divide. That machinery exists for the four gigabyte backup, and my tarball was never going to show it off. For the record, xz -9 wanted 357,564 KB to do its work, so nobody in this comparison is thrifty at the top of their range.
The sheet
Efficiency. 5.64x on text at the default setting, and it took 0.08 seconds. 624 MB/s going in at level 1, 1,560 MB/s coming back out. 137,416 bytes of binary in the base system, 73,763 lines of library behind it. 40,240 KB resident whilst compressing at the default, 12,396 KB whilst unpacking.
Security. Three CVE entries in ten years, all of them in the command line tool. Nothing so far in the frame decoder that ships inside ZFS and the Linux kernel. No network parsing of its own and no privileges required. BSD licence, with a GPLv2 option added in 1.3.1 in August 2017.
Longevity. Version 1.0 on 31 August 2016. In the FreeBSD base system and in OpenZFS since 13.0, in the Linux kernel, in Ubuntu and Fedora package payloads, in Chrome since March 2024 and Firefox since May 2024. Standardised as RFC 8878 in February 2021, extended by RFC 9659 in 2024.
Stability. The frame format has not moved since 1.0. Every 1.x release reads every stream written by every earlier 1.x release, stated as project policy and now fixed in an IETF document that anybody can implement against without asking permission.
Measured on 25 September 2026 in the jail test_and_benchmarks on FreeBSD 15.1-RELEASE-p3, twenty cores under moderate load, using zstd 1.5.2, gzip 20190107 and xz 5.8.1 from the base system; minimum of repeated runs reported.
Somewhere tonight a cron job is compressing yesterday's logs at level 19, because somebody once read that 19 was the best one, and the machine that reads them back at four in the morning will never notice the difference.
Nineteen compression levels, one decompression time. 0.05 seconds to pack at level 1, 7.39 at level 19, and 0.02 seconds to unpack at either, because RFC 8878 describes one decoder and the level never leaves the encoder. The default beat gzip -9 on size in a tenth of the time; xz -9 still wins the ratio on binaries by 8.8 per cent at equal cost. It sits in the FreeBSD base system because OpenZFS needed a decompressor on the read path, and the library that does it is three times the source of zlib.