Serializing GeoDataFrames for Redis with WKB
Store the frame as Zstd-compressed Parquet with WKB geometry and the EPSG code in the schema metadata — pickle is two to four times larger, several times slower to load, and breaks on the next GeoPandas upgrade.
Jump to heading Why this matters
A shared cache stores bytes, so every entry crosses a serialisation boundary twice: once on write and once on every hit. For a spatial dashboard that boundary is unusually expensive, because geometry is the bulk of the payload and the obvious approach — pickle.dumps(gdf) — is the worst of the available options on all three axes that matter.
It is large, because pickle stores each Shapely geometry as a serialised Python object with its own header rather than as packed coordinates. It is slow, because reconstructing those objects on read means allocating one Python object per feature. And it is brittle in a way that only shows up later: a pickle embeds the module path and class layout of what it stored, so the day GeoPandas or Shapely is upgraded, every entry written by the old version becomes a deserialisation error on a random worker, at a random time, long after the deploy that caused it.
Well-Known Binary solves all three. It is the standard binary encoding for geometry, it is compact, and it is a format rather than a memory layout, so it is stable across versions. The one thing it does not carry is the coordinate system, which is the subject of the most important step below.
Jump to heading Prerequisites
geopandas>=1.0,pyarrow>=14, andredis>=5.0.- A working shared cache, as set up in Redis as a shared cache layer. This page is the serialisation half of that page in detail.
- A frame whose CRS is set. A frame with
crs=Nonewill round-trip successfully and produce something unusable at the other end.
Jump to heading Step-by-step solution
Jump to heading Step 1 — Convert geometry to WKB and record the CRS
GeoSeries.to_wkb() returns a plain bytes column that Arrow stores as a binary column. The CRS is not part of WKB, so it has to travel separately — the schema metadata is the right place, because it stays attached to the file rather than to a convention both sides have to remember.
import io
import geopandas as gpd
import pyarrow as pa
import pyarrow.parquet as pq
def dumps(gdf: gpd.GeoDataFrame) -> bytes:
if gdf.crs is None:
raise ValueError("refusing to serialise a frame with no CRS")
frame = gdf.copy()
frame["geometry"] = frame["geometry"].to_wkb()
table = pa.Table.from_pandas(frame, preserve_index=False)
meta = {**(table.schema.metadata or {}), b"crs": gdf.crs.to_string().encode()}
table = table.replace_schema_metadata(meta)
buf = io.BytesIO()
pq.write_table(table, buf, compression="zstd", compression_level=3)
return buf.getvalue()
Raising on a missing CRS rather than defaulting to EPSG:4326 is deliberate. A frame that reaches the cache without a coordinate system has a bug upstream, and defaulting hides it until somebody notices that a predicate returns nothing — at which point the cache is several layers away from the actual cause.
Jump to heading Step 2 — Read it back and restore the geometry column
def loads(raw: bytes) -> gpd.GeoDataFrame:
table = pq.read_table(io.BytesIO(raw))
crs = (table.schema.metadata or {}).get(b"crs")
if crs is None:
raise ValueError("cache entry carries no CRS — written by an older writer")
frame = table.to_pandas()
frame["geometry"] = gpd.GeoSeries.from_wkb(frame["geometry"])
return gpd.GeoDataFrame(frame, geometry="geometry", crs=crs.decode())
Both halves raise on the same missing metadata, which means an entry written before this convention existed fails loudly on read rather than producing a frame that silently loses its projection.
Jump to heading Step 3 — Choose the compression level deliberately
Zstd level 3 is the sensible default: it is close to the compression of much higher levels on geometry data and several times faster to write. Levels above about 9 spend meaningful CPU for single-digit percentage gains, which is a poor trade on a path that runs on every cache write.
import time
for level in (1, 3, 9, 19):
t0 = time.perf_counter()
raw = dumps_at(gdf, level)
print(f"level {level:2d}: {len(raw)/1e6:6.1f} MB write {(time.perf_counter()-t0)*1000:5.0f} ms")
Run this once against a representative layer rather than trusting the default. Dense polygon geometry compresses well; a layer that is mostly already-simplified points sometimes barely compresses at all, and there the CPU is being spent for nothing.
Jump to heading Step 4 — Prune columns before serialising, not after
The cheapest bytes are the ones never written. A frame carrying thirty attribute columns of which the dashboard renders three should be pruned on the way into the cache, so every hit for the rest of the entry’s life reads a third of the payload.
RENDER_COLUMNS = ["feature_id", "name", "category", "geometry"]
raw = dumps(gdf[RENDER_COLUMNS])
Pruning at write time rather than at read time also makes the cache entry honest about what it holds: an entry that contains only what the map draws cannot accidentally become the source for an export that needed the other twenty-seven columns.
Jump to heading Verification
Assert equality on the three things that can silently change: the geometry, the coordinate system, and the dtypes.
raw = dumps(gdf[RENDER_COLUMNS])
back = loads(raw)
assert len(back) == len(gdf), "row count changed"
assert back.crs == gdf.crs, "CRS did not survive the round trip"
assert back.geometry.geom_equals(gdf.geometry).all(), "geometry changed"
assert back.dtypes.to_dict() == gdf[RENDER_COLUMNS].dtypes.to_dict(), "dtypes drifted"
print(f"{len(raw)/1e6:.1f} MB for {len(back):,} features")
geom_equals rather than == matters: it compares geometries spatially rather than by object identity, which is the question you actually want answered. And the dtype assertion catches the most common silent regression — a categorical column that comes back as an object column, doubling its memory the moment it is loaded.
Jump to heading Edge cases and gotchas
- Null geometry.
to_wkb()producesNonefor empty or missing geometries andfrom_wkb()restores them, so nulls survive — but a frame containing them will fail some predicates downstream. Decide whether to drop them before caching or to keep them, and make it explicit rather than incidental. - A mixed-geometry column. WKB handles mixed types fine, but a consumer expecting polygons will not. If the frame is supposed to be homogeneous, assert it before writing rather than discovering it in a renderer.
- Very large single entries. Redis moves a value as one unit, so a several-hundred-megabyte entry blocks the server while it transfers and delays every other client. Cache per-zoom simplified tiers separately rather than one full-fidelity entry.
- A schema change without a version bump. Adding a column to
RENDER_COLUMNSchanges whatloadsreturns, and old entries still hold the old shape. Bump the cache schema version in the key namespace so the shapes cannot mix.
Jump to heading FAQ
Why not use gdf.to_parquet() directly instead of converting to WKB?
You can, and for file storage you should — GeoParquet handles the geometry encoding and the CRS for you. The reason for the manual path here is control over the bytes: to_parquet writes to a path or a file object with its own metadata conventions, and for a cache you want an explicit, versioned encoding whose exact shape you decided, so that a library upgrade changing the default convention cannot silently make existing entries unreadable.
Is WKB lossy?
No. It stores full double-precision coordinates, so a round trip is exact. What it does not carry is the coordinate reference system, the geometry’s validity state, or any Shapely-level attributes — which is why the CRS has to travel in the metadata and why validity should be checked before writing rather than assumed afterwards.
Should I compress before or after Redis?
Before. Redis does not compress values, so an uncompressed payload occupies its full size in the instance’s memory and costs its full size on every transfer. Compressing in the serialiser means the instance holds less and the network moves less, and the decompression cost on read is smaller than the transfer cost it removes.
Back to Redis as a Shared Cache Layer.