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.

What Parquet encodings can do with each geometry representation Three representations — WKB blobs, split double columns, and scaled integer columns — with the encodings each permits and the resulting compressed size. representationencodings availablecompressed size WKB blob column the GeoParquet default block codec only — the bytes are opaque 296 MB split x / y double columns one row per vertex byte stream split + block codec 204 MB scaled int32 + delta one row per vertex, integer DELTA_BINARY_PACKED + bit packing + codec 88 MB The saving comes from making the data typed and small-valued, not from a switch labelled “delta”.

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.

Delta magnitude by vertex ordering Three vertex orderings and the share of deltas that fit in a small bit width: insertion order spreads deltas across the full extent, feature order helps within features, and Hilbert plus feature order puts most deltas within twelve bits. Share of coordinate deltas fitting in 12 bits or fewer (the bit-packing win) insertion order 14% · 268 MB feature order 61% · 143 MB Hilbert + feature order 96% · 88 MB — neighbouring features are adjacent, so between-feature jumps are small too Ordering is not an optimisation on top of delta encoding — it is what makes delta encoding work at all.

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.

Read cost by access pattern Four read patterns with the cost of reconstructing geometry from a vertex table compared with reading WKB directly. access pattern vs reading WKB why bulk analytical scan 1.12× faster less data transferred coordinate-only aggregate 3.0× faster no geometry parsing at all 50 whole features 2.4× slower grouping and ring assembly single feature lookup 6× slower row-group scan for one group Keep WKB alongside where whole-feature access matters; the vertex table is an analytical projection, not a replacement.

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.

Up one level: Coordinate Precision & Encoding Strategies.