PolyPress

Polypress · makes data tables much smaller, without changing them

A table is not
a stream of bytes.

Polypress makes data tables far smaller — typically 14× smaller — and gives them back exactly as they were. It works on CSV, Parquet, and anything else shaped like rows and columns.

"Lossless" means nothing is approximated or thrown away: every cell comes back as the exact text it was, in the original row and column order. If that is all you needed to know, install it. The rest of this page is how it works and where it fails.

General-purpose compressors (zip, gzip, xz, zstd) see a table as a stream of bytes. Columnar formats (Parquet, ORC, Feather) see it as columns, but compress each column on its own. Polypress is built on the observation that the columns of a real table are not independent of each other — City is almost determined by Postal Code, latitude is repeated inside location, adjacent sensor columns hold nearly the same number — and it exploits those relationships explicitly.

The output is a .ppz file. The original table comes back exactly: same columns, same column order, same row order, every cell as the exact string it was.

Read the source

The codec, the C encoder, the tests and the benchmark harness, with the sweep evidence committed under OUT/results/. Public source is not reviewed source — nobody outside this project has run it.

01

The three ideas it is built on

  1. Predict down a column

    Fit a low-degree polynomial to the last few values in a column, extrapolate one step, store only the error. Because the fit is re-centred at every cell, the coefficients never need to be stored — the decoder already has the neighbours. Order-k extrapolation turns out to be exactly the k-th finite difference, so this is one np.diff call.

  2. The same thing in two dimensions

    Where adjacent numeric columns are commensurable — same decimal places, same magnitude — a cell is predicted from its left, upper, and upper-left neighbours. This is what wins on matrix-shaped tables, and it is the one thing no shipped columnar codec does: Gorilla, DoubleDelta, T64, zfp and fpzip all predict down a single column. The detection matters as much as the predictor — differencing Model Year against Make is meaningless, so groups form only where columns are genuinely comparable.

  3. Reorder the rows so a column collapses into runs

    This is the central idea. Rather than model the fact that City depends on Postal Code, sort the rows by the parent column: equal parents become adjacent, the child collapses into long runs, and the entropy coder eats it. Parents are picked by conditional entropy, then confirmed by actually compressing both ways, and arranged into a tree so a parent is always decoded before its children.

As stored. City is scattered, so nothing repeats locally.
PostalCity
60614Chicago
94110San Francisco
10025New York
60614Chicago
94110San Francisco
60614Chicago
10025New York
94110San Francisco
sort by
parent
→
Sorted by Postal. City is now three runs.
PostalCity
10025New York
10025New York
60614Chicago
60614Chicago
60614Chicago
94110San Francisco
94110San Francisco
94110San Francisco

Two details distinguish this from most of the prior work. The ordering is per-column, not global — a 421-column table can carry hundreds of different row orderings at once. And the permutation is neither stored nor imposed on the output: you get your table back in its original row order.

02

500 datasets nobody chose

The obvious objection to any compression result is “you picked the files”, and there is no way to answer that by picking more files. So the main benchmark does not pick. It asks the Socrata open-data catalog — the index behind several hundred government data portals — for its datasets in descending order of page views, and takes the first 500 that survive four mechanical filters: the CSV downloads; at least 2 columns and 20 rows; at least 50 KB; not a byte-identical duplicate of one already taken.

Rank order is public and fixed, so the list reproduces. Every rejection is recorded with its reason. That gives 500 tables, 3.57 GB of CSV, 14.3 million rows, 10,557 columns, measured against 17 competing codecs.

500 / 500round-trip exactly
478 / 500smaller than the best of all 17 competitors
1.25×median margin over the best other tool
14.35×median compression vs raw CSV
Whole corpus, aggregate: 222.8 MB against 280.2 MB — 1.26× smaller. Best margin 2.73×, worst 0.65×. Best compression vs CSV 231.78×, worst 3.49×. Peak memory across the whole sweep, 1,148 MB.
CompetitorPolypress winsMedian margin
xz -9e500 / 5001.30×
zstd -22 ultra500 / 5001.39×
gzip -9500 / 5002.24×
lz4 -9500 / 5002.65×
parquet + zstd449 / 4501.71×
parquet + snappy450 / 4502.56×
feather + zstd450 / 4503.36×
orc + zstd435 / 4362.00×
bzip2 -9491 / 5001.46×
brotli -q 11487 / 5001.32×

Counts differ because pyarrow could not read every CSV: 50 files defeated its Parquet/Feather reader, 64 its ORC writer.

03

Two things that must be said next to those numbers

Parquet did not reproduce the data on 356 of the 500 tables

Read with type inference — the way a data engineer actually reads a CSV — Parquet returned the exact printed text on only 81 of 500. Turning "1.50" into 1.5, or 007 into 7, makes a smaller file for reasons that have nothing to do with compression. Polypress guarantees the exact printed cell. So on 71% of the corpus the Parquet rows above are flattering to Parquet — and it still loses all but one of them.

“You just picked a better final compressor” is answered

Polypress finishes with xz, and Parquet cannot use xz at all. So the same modelled streams were re-finished with the competitor's own entropy coder: polypress+zstd beats parquet+zstd 449/450 (median 1.57×), and polypress+brotli beats parquet+brotli 449/450 (median 1.63×). The margin is the modelling, not the finisher.

04

Where it wins, and where it doesn't

The win is decided by what fraction of the output is free text, not by how wide the table is. On chicago_permits — 116 columns, 70.9% free text — the margin is 1.12×. On cdc_nndss, at 7.5% free text, it is 3.70×.

GenreResultWhy
Densely-coded administrative and survey data 2–4× Disease surveillance, health surveys, permit and incident logs. These tables are made of codes, and the reordering trick does nearly all the work.
Matrix-shaped numeric tables 1.86× · 2.14× · 1.66× A Treasury yield curve and two sensor grids. This is the 2D predictor's own case.
Free-text-heavy tables weak Addresses, names, descriptions. LZ77 substring matching is already good here, and modelling adds little.

It loses on 22 of 500, and all 22 are listed publicly

Twelve losses are to brotli -q 11, which is not one of the carried fallbacks. Nine are to bzip2 -9 run on the original file — Polypress's fallback compresses the table re-rendered canonically, and on those tables normalising the quoting removed redundancy the Burrows–Wheeler transform had been exploiting; the gap is 0.1%–1.6%. That leaves one genuine loss, sars_cov_2_variant_proportions at 0.65× to orc+zstd, and it is undiagnosed.

The honest form of the guarantee is never worse than our own plain fallback — not “never worse than any tool on your original bytes.”
05

What it is not — the withdrawn claim

An earlier version of the README claimed the reordering trick was novel. That claim is withdrawn. A prior-art search found US 8,312,026 B2 (Kiem-Phong Vo, AT&T, filed 2009, granted 2012), which discloses the whole of it — including the rule that a parent must be chosen by measured compressed size rather than an entropy score, which this project learned the hard way.

The predictors are also prior art: the planar predictor is Lorenzo (2003, used in fpzip and SZ); MED is JPEG-LS. Row reordering for compression is a studied problem (Lemire, Kaser & Gutarra, ACM TODS 2012).

The design was reached here independently, without knowledge of any of it. Independent convergence twenty years apart is decent evidence the design is right; it is not evidence of priority. Both relevant patents have expired, so there is no restriction on using the code.

The honest framing: a careful, measured, verified implementation of a good idea, with a table-aware front end, taken further and tested harder than the prior art was.

06

Honest limitations

It is slow to compress

Median across 100 unselected datasets: 2.0 MB/s encode, 134 MB/s decode. Decompression is fast; compression is in the slow tier and will stay there. The “never worse” guarantee is what costs it — tables get encoded twice so the smaller result can win.

Same machine, encode / decode throughput.
ToolEncodeDecode
polypress2.0 MB/s134 MB/s
xz -9e3.9 MB/s203 MB/s
brotli -q 111.1 MB/s501 MB/s
zstd -222.8 MB/s871 MB/s
zstd -3187 MB/s716 MB/s
07

How you actually use it

# the codec and the `polypress` command
pip install polypress
# add pyarrow, for .parquet in and out
pip install 'polypress[parquet]'

polypress compress data.csv                      # -> data.csv.ppz
polypress restore  data.csv.ppz                  # -> data.csv
polypress restore  data.csv.ppz -o out.parquet   # doubles as a converter
polypress info     data.csv.ppz                  # plan, shape, how much was reordered

Version 0.2.0, MIT. Python 3.9+ and numpy; a C compiler is optional — the accelerator builds itself on first import and falls back to numpy without one. The source is public, so git clone then pip install . from work/ also works, and python3 work/tzip.py runs out of the checkout with nothing installed.

For files larger than RAM, a block-at-a-time mode with a settable memory budget (--budget 1.0 for roughly 1 GB peak). Blocks compress independently, so peak memory is one block rather than one file; the cost is that reordering only sees correlations inside a block.

There is also a standalone C binary — no Python, no numpy — that produces byte-identical output to the Python encoder, verified on 108 real datasets. And a Mac app: launch it and pick a table, double-click a .ppz to restore it, or drop files on the Dock icon. Nothing is written until the compressed blob has been decoded in memory and compared to the original.