Delta Encoding Vertex Coordinates in GeoParquet
Consecutive vertices of a polygon are close together, so the differences between them are small numbers with far less entropy than the absolute coordinates they came from. Delta encoding exploits that, and on dense geometry it is the single largest saving available after the codec itself. This walkthrough is for the engineer applying it to a GeoParquet archive — including the honest part, which is that WKB geometry does not delta-encode on its own and getting the benefit means changing how coordinates are stored.
Why WKB Blocks the Saving
A WKB geometry column is a binary blob per feature. Parquet’s encodings operate on typed columns, so a blob column gets no delta encoding, no bit packing and no dictionary — only the block codec, which sees a stream of full-precision doubles inside an opaque payload.
Step-by-Step Procedure
Step 1 — Decide where the encoded copy lives
Two arrangements work, and they suit different archives. The first keeps WKB as the primary geometry and adds encoded coordinate columns beside it for analytical scans — costing storage but keeping universal readability. The second stores only the encoded columns and reconstructs geometry on read, which is compact and requires a reader that knows the convention.
Step 2 — Explode geometry to a vertex table
import geopandas as gpd, numpy as np, pyarrow as pa
from shapely import get_coordinates, get_parts
gdf = gpd.read_parquet("staging/parcels_2026.parquet") # already rounded to a 1 cm grid
coords, index = get_coordinates(gdf.geometry.values, return_index=True)
scale, offset = 0.01, np.array([400000.0, 5300000.0]) # match the rounding grid
xi = np.rint((coords[:, 0] - offset[0]) / scale).astype(np.int32)
yi = np.rint((coords[:, 1] - offset[1]) / scale).astype(np.int32)
vertices = pa.table({
"feature_id": pa.array(gdf.index.values[index], type=pa.int64()),
"x": pa.array(xi, type=pa.int32()),
"y": pa.array(yi, type=pa.int32()),
})
Scaling to integers at the same grid the rounding used is what makes the values small and exactly representable — a float that has been rounded to a grid is still a float, and still carries a full mantissa.
Step 3 — Write with delta encoding enabled
import pyarrow.parquet as pq
pq.write_table(
vertices,
"s3://spatial-archive/archive/parcels/2026/vertices.parquet",
compression="zstd", compression_level=12,
use_dictionary=False, # never dictionary-encode coordinates
column_encoding={"x": "DELTA_BINARY_PACKED",
"y": "DELTA_BINARY_PACKED",
"feature_id": "DELTA_BINARY_PACKED"},
write_statistics=True,
row_group_size=2_000_000, # vertices, not features
sorting_columns=[pq.SortingColumn(0)], # feature order preserved
)
Sorting matters as much as the encoding: delta encoding rewards locality, so vertices in spatial order produce far smaller deltas than vertices in arbitrary order. Writing features in Hilbert order before exploding them compounds the two effects.
Step 4 — Reconstruct on read
import pyarrow.parquet as pq, numpy as np
from shapely import polygons
t = pq.read_table("s3://spatial-archive/archive/parcels/2026/vertices.parquet",
filters=[("feature_id", "in", [4471, 4472])])
x = t["x"].to_numpy() * 0.01 + 400000.0
y = t["y"].to_numpy() * 0.01 + 5300000.0
# group by feature_id, then build rings — exact within the 1 cm grid
Validation & Verification
python3 - <<'PY'
import pyarrow.parquet as pq
md = pq.ParquetFile("vertices.parquet").metadata
for rg in range(min(2, md.num_row_groups)):
for c in range(md.row_group(rg).num_columns):
col = md.row_group(rg).column(c)
print(rg, col.path_in_schema, col.encodings,
round(col.total_compressed_size / col.total_uncompressed_size, 3))
PY
# 0 x ('DELTA_BINARY_PACKED',) 0.108
# 0 y ('DELTA_BINARY_PACKED',) 0.111
Expected output shows DELTA_BINARY_PACKED on every coordinate column of every row group and a compression fraction near 0.1. A column reporting PLAIN means the encoding request was ignored — most often because the column is a float type, for which delta binary packing does not apply.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
Encoding reports PLAIN |
Columns are floats, not integers | Delta binary packing applies to integer types; scale to int32 first |
| Saving far below expectation | Vertices in arbitrary order | Sort features spatially before exploding to vertices |
| Reconstructed coordinates off by a scale step | Scale or offset differs between write and read | Store both in the file metadata, never in code |
| Row groups enormous | row_group_size counted in features, not vertices |
A feature can hold thousands of vertices; size against vertex count |
| int32 overflow on large extents | Extent divided by scale exceeds 2.1 billion | Raise the grid size or use int64 for continental extents |
Operational Execution Checklist
Reconstruction Cost on the Read Path
Storing coordinates as a vertex table moves work from write time to read time, and the size of that work decides whether the arrangement suits an archive. For bulk analytical reads it is negligible; for a client wanting a handful of whole features it is not.
That split is the honest summary: the encoding is excellent for archives whose dominant workload scans coordinates and poor for archives that serve individual features. Most institutional archives do both, which is why keeping both representations is the common answer despite its storage cost.
Frequently Asked Questions
Is this compatible with standard GeoParquet readers?
The vertex table is an ordinary Parquet file and reads anywhere, but it is not a GeoParquet geometry column, so a standard reader will not interpret it as geometry. Archives that need both keep the WKB column as the interoperable representation and the vertex table as the analytical one — a storage cost paid for by scans that touch only coordinates.
Does the saving survive an update?
Yes, provided the update rewrites the affected partition rather than appending. Appended vertices land outside the spatial ordering, so their deltas are large and the affected row groups lose most of the benefit. That is the same degradation appending causes to a sorted FlatGeobuf, and the same remedy — compaction — applies.
How much does the extra column cost if both are kept?
The vertex table is typically 30–35% of the WKB column’s compressed size, so keeping both costs about a third more than WKB alone. Whether that is worth it depends on how much of the workload is coordinate-only scanning: for archives that mostly retrieve whole features, it is not.
Does the vertex table need its own spatial index?
It inherits pruning from the row-group statistics on the coordinate columns, which are min and max values — effectively a bounding box per row group. That is enough for extent queries when the vertices are in spatial order. What it does not give is per-feature pruning, so a workload that selects features by identifier should keep the feature-level table as the entry point.
How are multi-part geometries and interior rings represented?
With additional columns: a part index and a ring index alongside the feature identifier. Both delta-encode extremely well because they are small, mostly constant integers, so the cost is negligible. Omitting them makes reconstruction ambiguous for anything more complex than a simple polygon, which is a large fraction of real data.
Is this arrangement standard enough for an archive?
The file is standard Parquet, so it will open anywhere in twenty years; the convention for reconstructing geometry from it is local to your archive, so it must be documented in the manifest alongside the scale and offset. That is an acceptable trade for an analytical projection and not for an archive of record, which is why the WKB column usually stays.
Does the vertex table need to be regenerated when the geometry is corrected?
Yes, for the affected features, and the regeneration should be part of the same job that writes the corrected geometry. Keeping two representations of the same data means keeping them in step, and the only reliable way to do that is to derive both from one source in one operation rather than maintaining them separately.
How is the arrangement documented for a future reader?
In the dataset manifest, with the scale, the offset, the column meanings and the reconstruction rule stated explicitly. A vertex table is self-describing as Parquet and not as geometry, so the convention has to be written down somewhere durable — and the manifest, which travels with the data, is the only place that qualifies.
Is the vertex table worth building for small datasets?
Rarely. The saving is proportional to the geometry column, and below a few gigabytes the added complexity — a second representation, a reconstruction convention, a documentation obligation — outweighs it. Reserve the arrangement for the collections where the geometry column is genuinely large.
Related
- Coordinate Precision & Encoding Strategies — the parent topic, including where this sits against precision reduction.
- Reducing Coordinate Precision Before Archival — the rounding step that must come first.
- Calculating Optimal Row Group Size for Spatial Queries — sizing row groups when the rows are vertices.
- Partitioning GeoParquet by H3 Spatial Index — the spatial ordering that makes deltas small.
Up one level: Coordinate Precision & Encoding Strategies.