Tuning ZSTD Compression for GeoParquet Archives
Default ZSTD settings applied to GeoParquet archives routinely produce suboptimal cold-storage ratios and elevated decompression latency, because generic columnar defaults assume one statistical profile while a GeoParquet file actually carries three very different ones: high-redundancy coordinate arrays, near-static CRS metadata, and high-cardinality GIS attributes. A blanket zstd(level=3) across all of them under-compresses geometry and wastes a dictionary page on float columns that can never benefit from it. This walkthrough is for the data engineer, GIS archivist, or cloud architect who has already settled an overall ZSTD Level Configuration for Spatial Files policy and now needs the exact column-level GeoParquet writer configuration, the validation thresholds that prove it worked, and the troubleshooting paths for the failures unique to spatial data.
Tuning Workflow
GeoParquet ZSTD tuning proceeds from a baseline measurement to a benchmarked write:
Before You Start
This procedure tunes a file that is already GeoParquet. If geometry is still in Shapefile or GeoPackage, run the GeoParquet Migration Workflows pipeline first so geometry encoding and CRS metadata land in proper column chunks — compressing an un-migrated layout just locks in a bad structure at a smaller size. You should also have a row-group target from Calculating Optimal Row Group Size for Spatial Queries, because ZSTD match-finding runs across the whole group and the group size changes the ratio a given level delivers.
Target the following operational thresholds for the cold tier, and treat them as the pass/fail gate for the steps below:
- Compression ratio ≥ 3.5:1 for geometry columns
- Decompression latency ≤ 120 ms per 100 MB row group on a standard cloud VM (e.g.
c6i.large/t3.medium) - Storage footprint reduction ≥ 28% versus the default
zstd(level=3)
Step-by-Step Procedure
Step 1 — Baseline the existing archive
Before changing any parameter, capture per-column compressed/uncompressed sizes so the after-tuning numbers have something to beat. Isolating geometry columns from attribute columns here is what later lets you assign parameters per column type rather than uniformly.
import pyarrow.parquet as pq
import pandas as pd
meta = pq.read_metadata("archives/cadastral/2024/parcels_input.parquet")
stats = []
for i in range(meta.num_row_groups):
rg = meta.row_group(i)
for j in range(rg.num_columns):
col = rg.column(j)
stats.append({
"row_group": i,
"column": col.path_in_schema,
"total_compressed": col.total_compressed_size,
"total_uncompressed": col.total_uncompressed_size,
"ratio": col.total_uncompressed_size / max(col.total_compressed_size, 1),
})
baseline_df = pd.DataFrame(stats)
print(baseline_df.groupby("column")["ratio"].mean())
Step 2 — Align row group boundaries with spatial extents
ZSTD dictionary effectiveness degrades when a row group splits spatially contiguous geometries, because match-finding can no longer exploit the locality between neighbouring features. Derive the target row count from the row-group sizing formula, then cap it so groups never straddle a spatial partition.
import pyarrow.parquet as pq
# Disable dictionary encoding for geometry/CRS float columns; keep it elsewhere.
dict_map = {
col: ("geometry" not in col and "crs" not in col)
for col in table.column_names
}
pq.write_table(
table,
"archives/cadastral/2024/parcels_tuned.parquet",
row_group_size=1_000_000, # rows per group, tuned toward a ~256 MB target
compression="zstd",
compression_level=9, # 1-22; applied to every zstd column
use_dictionary=dict_map,
write_statistics=True, # per-column min/max enables predicate pushdown
)
Misaligned groups trigger dictionary cache misses during cold retrieval, so confirm the alignment with a metadata scan (pq.read_metadata(...).row_group(i)) before promoting the file.
Step 3 — Configure ZSTD parameters for geometry columns
Coordinate arrays show high sequential redundancy but low cross-column correlation, so the single lever that matters in PyArrow is compression_level. Level 9 is the practical sweet spot for cold archival: levels above 11 yield less than 2% additional ratio for a 40%+ write-CPU penalty, while dictionary encoding on float geometry only inflates dictionary pages without ever finding repeats. (For the categorical attribute columns that do benefit, follow the cardinality thresholds in When to Use Dictionary Encoding for Categorical GIS Fields.)
PyArrow does not expose ZSTD’s advanced frame parameters (window log, chain log, hash log, minimum match). If a workload genuinely needs them, compress the raw column buffers with the zstd CLI outside the Parquet writer, where they are configurable:
# Advanced ZSTD frame tuning lives in the zstd CLI, not PyArrow.
zstd --ultra -22 --long=27 -c archives/cadastral/2024/coords.bin \
> archives/cadastral/2024/coords.bin.zst
Reading the Per-Column Report
The tuned write produces one number per column chunk, and the report only becomes useful when those numbers are grouped by the decision that produced them. Geometry columns should show a modest ratio and PLAIN encoding; categorical columns a high ratio and RLE_DICTIONARY; identifier columns a low ratio and PLAIN. A column in the wrong group is the signal that a writer option did not land, and it is far easier to see in a grouped view than in a flat list of forty columns.
Two failure signatures show up in this view immediately. A categorical column reporting PLAIN means the dictionary hit its page ceiling — raise the limit or shrink the row group. A geometry column reporting a ratio above four usually means coordinates were rounded more aggressively than intended somewhere upstream, which is good news for the footprint and worth confirming against the precision recorded in the manifest before it is celebrated.
Validation & Verification
Do not trust the job config — confirm the artifact. First check that the codec and level actually landed on every column chunk:
parquet-tools inspect archives/cadastral/2024/parcels_tuned.parquet \
| grep -iE "path|compression"
Expected output (geometry and attribute chunks both report the pinned codec):
path: geometry compression: ZSTD
path: parcel_id compression: ZSTD
path: land_use_code compression: ZSTD
Then verify the geometry ratio and cold-read latency against the thresholds from the start of this page. Do not rely on file size alone — measure actual decompression throughput:
import pyarrow.parquet as pq
import time
pf = pq.ParquetFile("archives/cadastral/2024/parcels_tuned.parquet")
meta = pf.metadata
# 1. Geometry compression ratio gate
for i in range(meta.num_row_groups):
rg = meta.row_group(i)
for col_idx in range(rg.num_columns):
col = rg.column(col_idx)
if "geometry" in col.path_in_schema:
ratio = col.total_uncompressed_size / col.total_compressed_size
assert ratio >= 3.5, f"RG {i} geometry ratio {ratio:.2f} < 3.5:1"
# 2. Decompression latency gate (per row group)
for i in range(meta.num_row_groups):
start = time.perf_counter()
pf.read_row_group(i) # force full decompression into memory
elapsed_ms = (time.perf_counter() - start) * 1000
rg_mb = sum(c.total_compressed_size for c in meta.row_group(i).columns) / (1024**2)
print(f"RG {i} ({rg_mb:.1f} MB) decompressed in {elapsed_ms:.1f} ms")
assert elapsed_ms <= 120, f"latency {elapsed_ms:.1f} ms exceeds 120 ms threshold"
print("validation OK")
A clean run prints one timing line per row group and ends with validation OK; any AssertionError points directly at the symptom you tune against below.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Geometry ratio < 2.8:1 | compression_level too low, or a row group splits contiguous spatial features |
Raise compression_level toward 11 and recompute the row count so groups don’t straddle spatial partitions |
| Decompression latency > 180 ms | Oversized row groups force whole-group decode for a small predicate result | Reduce row_group_size; align groups to typical query extents |
| OOM during read | Dictionary encoding forced on high-cardinality float columns | Set use_dictionary=False for geometry/CRS columns; confirm encodings with parquet-tools |
| Inconsistent ratios across partitions | Mixed CRS or varying coordinate precision within one column | Normalise CRS to EPSG:4326 or EPSG:3857 pre-write and round coordinates to 6 decimals; see CRS synchronization below |
Codec reports UNCOMPRESSED / SNAPPY |
Engine default overrode the writer option (level passed but compression omitted) |
Set both compression="zstd" and compression_level on the write call, then re-inspect |
Uneven ratios across partitions almost always trace back to CRS drift; lock a single projection upstream with Automating CRS Transformations in ETL Pipelines for Spatial Data Archival so every partition presents the same coordinate distribution to ZSTD. For compliance, store the chosen dict_map and compression_level alongside each dataset manifest, and confirm that object-storage lifecycle policies do not re-encode files on read — that silently invalidates the tuned ZSTD contexts you just verified.
Operational Execution Checklist
Frequently Asked Questions
Why does the same level give different ratios on two GeoParquet files with the same schema?
Because ratio follows the data, not the schema. Feature density, vertex counts per geometry, coordinate precision, and the mix of categorical values all differ between partitions, and each moves the ratio independently. A useful check is to compare bytes per feature rather than the ratio itself: that normalises away the row-count difference and usually shows that two apparently divergent partitions are behaving identically per feature.
Should the compression level be set on the write call or in the engine’s session config?
On the write call. Session-level defaults are silently overridden by engine-specific behaviour, by table properties in catalogued formats, and by any library that constructs its own writer properties. Setting both compression and compression_level explicitly on the call that produces the archive object is the only form that survives a library upgrade, and asserting the result in the column metadata afterwards is the only way to know it did.
Does the tuning need to be repeated when the writer library is upgraded?
Re-verify rather than re-tune. ZSTD levels are stable across versions in what they mean, but writer defaults for dictionary limits, page sizes, and statistics have changed between library releases more than once. Running the existing validation gate against a sample write after an upgrade catches a changed default in minutes; a full re-tune is only warranted if the measured ratios have moved materially.
Related
- ZSTD Level Configuration for Spatial Files — the parent topic that sets per-tier level policy this column-level walkthrough refines.
- Calculating Optimal Row Group Size for Spatial Queries — the sibling procedure that produces the row-group target Step 2 caps.
- GeoParquet Migration Workflows — convert legacy formats to GeoParquet before any compression tuning begins.
Validate parameter limits against the Zstandard compression manual and GeoParquet column conformance against the OGC GeoParquet specification.
Up one level: ZSTD Level Configuration for Spatial Files.