Converting Shapefiles to GeoParquet with GeoPandas
TL;DR: Read the Shapefile with an explicit encoding, confirm its CRS is set and projected, rename any 10-character-truncated field names back to full tokens, then call gdf.to_parquet("out.parquet", compression="zstd", write_covering_bbox=True) and verify by reading it back and asserting CRS, row count, and attribute values match.
Why Convert Shapefiles to GeoParquet
A legacy Shapefile store is the single biggest drag on a scaled farm-data workflow. Every read walks the full DBF even when one column is needed, dense yield layers creep toward the 2 GB .shp/.dbf ceiling, attribute names over 10 characters are already truncated, and a missing .prj silently discards the CRS. GeoParquet fixes all four: columnar reads that touch only the columns and spatial windows a query names, several-fold smaller files, unlimited field names, and CRS embedded in one file. On a multi-season archive the read-time and storage difference is not marginal — filtered scans that took seconds against a Shapefile complete in a fraction of the time once the same data is columnar. This guide converts a field-boundary or yield-point Shapefile to GeoParquet without losing CRS, mangling encoding, or leaving truncated names unrepaired.
For where GeoParquet fits among the other pipeline formats, this guide sits under Geospatial File Formats for Farm Data Pipelines, and the format trade-offs are compared directly in GeoPackage vs GeoParquet vs Shapefile for farm data.
Prerequisites
Only the Parquet backend differs from the parent environment:
geopandas==0.14.4
pyogrio==0.9.0
pyarrow==16.1.0
pip install geopandas==0.14.4 pyogrio==0.9.0 pyarrow==16.1.0
Input requirements:
- A Shapefile set with all sidecars present —
.shp,.shx,.dbf, and crucially.prj(the CRS). A.cpgdeclaring the attribute encoding is ideal but often absent. - Know your source encoding. Older FMIS and controller exports frequently use a legacy code page (
latin-1/cp1252) rather than UTF-8.
Step-by-Step
Step 1 — Read with an explicit encoding and confirm the CRS
Do not rely on the default encoding. If the DBF was written with a legacy code page and no .cpg, reading it as UTF-8 corrupts accented crop names and operator notes. Read with the known encoding, then assert the CRS is present and projected before doing anything else.
import geopandas as gpd
SRC = "yield_points_2025.shp"
# Read with the source's real encoding (latin-1 is common in legacy exports)
gdf = gpd.read_file(SRC, engine="pyogrio", encoding="latin-1")
assert gdf.crs is not None, "Source .prj missing — set the CRS explicitly before writing"
print(f"CRS: EPSG:{gdf.crs.to_epsg()} projected={gdf.crs.is_projected}")
print(f"Features: {len(gdf):,} columns: {list(gdf.columns)}")
If the source .prj was missing, gdf.crs is None. Set the known CRS explicitly — never guess — using the reasoning in understanding CRS in precision agriculture:
if gdf.crs is None:
gdf = gdf.set_crs("EPSG:32615") # replace with the field's actual UTM zone
Step 2 — Restore truncated field names
The Shapefile has already truncated any name longer than 10 characters. GeoParquet will faithfully carry whatever names it receives, so repair them now with a mapping you maintain. This is also the moment to catch collisions where two long names truncated to the same stub.
# Map the truncated stubs the Shapefile actually stores back to full names
RESTORE = {
"prescripti": "prescription_rate",
"yield_bu_a": "yield_bu_ac",
"moisture_p": "moisture_pct",
}
present = {old: new for old, new in RESTORE.items() if old in gdf.columns}
gdf = gdf.rename(columns=present)
print("Restored names:", present)
# Guard against a silent truncation collision
assert len(set(gdf.columns)) == len(gdf.columns), "Duplicate column names after rename"
Step 3 — Write GeoParquet
Write with zstd compression for the smallest file on repetitive farm attributes, WKB geometry encoding for portability, and write_covering_bbox=True so later spatial filters can skip row groups.
gdf.to_parquet(
"yield_points_2025.parquet",
compression="zstd",
geometry_encoding="WKB",
write_covering_bbox=True,
)
Step 4 — Verify the round-trip
Read the output back with geopandas.read_parquet (not plain pandas) and assert that CRS, feature count, and a sorted checksum of a key column all match the source.
The complete, directly runnable script:
import numpy as np
import geopandas as gpd
SRC = "yield_points_2025.shp"
DST = "yield_points_2025.parquet"
SRC_ENCODING = "latin-1"
FALLBACK_EPSG = 32615 # the field's actual UTM zone; only used if .prj is missing
RESTORE = {
"prescripti": "prescription_rate",
"yield_bu_a": "yield_bu_ac",
"moisture_p": "moisture_pct",
}
KEY_COL = "yield_bu_ac"
# ── 1. Read with explicit encoding and confirm CRS ────────────────────────
gdf = gpd.read_file(SRC, engine="pyogrio", encoding=SRC_ENCODING)
if gdf.crs is None:
gdf = gdf.set_crs(f"EPSG:{FALLBACK_EPSG}")
assert gdf.crs.is_projected, "Expected a projected CRS for metric farm analytics"
src_epsg = gdf.crs.to_epsg()
# ── 2. Restore truncated field names ──────────────────────────────────────
present = {old: new for old, new in RESTORE.items() if old in gdf.columns}
gdf = gdf.rename(columns=present)
assert len(set(gdf.columns)) == len(gdf.columns), "Column-name collision after rename"
assert KEY_COL in gdf.columns, f"Key column '{KEY_COL}' missing after rename"
n_src = len(gdf)
checksum_src = np.sort(gdf[KEY_COL].dropna().to_numpy())
# ── 3. Write GeoParquet ───────────────────────────────────────────────────
gdf.to_parquet(DST, compression="zstd", geometry_encoding="WKB",
write_covering_bbox=True)
# ── 4. Read back and assert fidelity ──────────────────────────────────────
back = gpd.read_parquet(DST)
assert back.crs is not None and back.crs.to_epsg() == src_epsg, (
f"CRS drift: {src_epsg} -> {back.crs.to_epsg() if back.crs else None}"
)
assert len(back) == n_src, f"Row count changed: {n_src} -> {len(back)}"
checksum_dst = np.sort(back[KEY_COL].dropna().to_numpy())
assert np.array_equal(checksum_src, checksum_dst), f"'{KEY_COL}' values changed on write"
print(f"OK: {n_src:,} features, EPSG:{src_epsg}, '{KEY_COL}' preserved")
# Prove the columnar read advantage: pull just geometry + one column
subset = gpd.read_parquet(DST, columns=["geometry", KEY_COL])
print(f"Column-subset read returned {len(subset):,} rows, "
f"{len(subset.columns)} columns")
Running against a real yield-point layer prints the feature count, the preserved EPSG code, and confirmation that the key measured column is byte-for-byte identical after the round-trip — the proof that the conversion lost nothing an analytical stage depends on.
Gotchas & Edge Cases
- Reading GeoParquet with
pandas.read_parquetloses geometry and CRS. Always usegeopandas.read_parquet. Plain pandas returns the WKB geometry as opaque bytes with no CRS attached, and a downstream spatial join silently fails. - A missing
.prjyieldscrs=None, and the output inherits it. GeoParquet cannot invent a CRS. If Step 1 finds no CRS, you mustset_crsthe correct EPSG before writing — guessing WGS84 on data that is actually UTM corrupts every area and distance calculation downstream. - Truncated names are not recoverable from the file. The 10-character truncation is destructive and happened at Shapefile write time. Only a mapping you maintain restores the full names; there is no metadata in the
.dbfthat records the original. write_covering_bboxneeds a recentpyarrow/geopandas. On older stacks the argument is ignored and no bbox column is written, so later spatial predicate pushdown quietly does nothing. Confirm the column exists in the output schema if you rely on spatial filtering.- Very large layers should be written in row groups, not one block. For multi-million-point archives, let
pyarrowchunk into row groups (the default) so readers can skip them; a single giant row group defeats predicate pushdown.
Parent Guide
This guide is part of Geospatial File Formats for Farm Data Pipelines — see there for the full pipeline context, including where GeoParquet sits relative to GeoPackage, Shapefile, and Cloud Optimized GeoTIFF.
Related
- GeoPackage vs GeoParquet vs Shapefile for Farm Data — the decision matrix that justifies moving to GeoParquet in the first place
- Field Boundary Extraction with GeoPandas — cleaning boundary geometry before it is stored in the columnar layer
- Understanding CRS in Precision Agriculture — setting the correct CRS when a source .prj is missing
- Geospatial File Formats for Farm Data Pipelines — the parent guide covering all vector and raster formats in the pipeline