Converting Shapefiles to GeoParquet for Annotation

Legacy annotation deliverables almost always arrive as Shapefiles — a fragile bundle of .shp, .shx, .dbf, and .prj files that must stay together, whose attribute names are clipped to ten characters, and whose coordinate reference system lives in a plain-text .prj file that anything can overwrite. Converting to GeoParquet fixes all three problems at once. The reliable path is short: read the Shapefile with geopandas, confirm or assign the CRS from the .prj, repair any truncated or duplicated field names, then write GeoParquet — which embeds the CRS directly as file metadata, removes the 10-character .dbf field-name limit, and collapses the multi-file sidecar cluster into one self-describing file. Finish by validating the roundtrip so you can prove no geometry or projection information was lost.

Why the Shapefile Sidecar Cluster Fails ML Pipelines

A Shapefile is not one file; it is a minimum of three and usually four, and every consumer must keep them adjacent with identical basenames. Drop the .prj during an scp and the CRS is gone. Zip only the .shp and the dataset is unreadable. That fragility is tolerable for a one-off GIS map but corrosive for an annotation pipeline where thousands of label files move between object storage, a labeling platform, and a training loader on every iteration. The attribute table compounds the problem: it is a dBASE .dbf, so field names are hard-capped at 10 characters. A column you named annotation_confidence is written to disk as annotatio_c, and if you also have annotation_class the two can truncate into a collision that the driver resolves by silently renaming one. Feed that into a training script keyed on column names and the run fails, or worse, trains on the wrong field.

GeoParquet removes each failure mode. It is a single Parquet file whose geometry column is stored as Well-Known Binary and whose CRS is written as PROJJSON into a file-level geo metadata key, so the projection travels inside the bytes that hold the coordinates. Parquet’s schema imposes no practical limit on column-name length, so full annotation field names survive. And because Parquet is columnar and compressed, a loader that needs only geometry and a label reads those columns alone.

Shapefile sidecar cluster consolidated into a single GeoParquet file On the left, four separate files labelled .shp, .shx, .dbf, and .prj are grouped as a fragile cluster, with notes that the .dbf truncates field names to ten characters and the .prj holds the CRS separately. An arrow labelled geopandas convert points to the right, where a single GeoParquet file contains embedded WKB geometry, the CRS as PROJJSON metadata, and full-length field names. Shapefile sidecar cluster .shp geometry .shx index .dbf attrs .prj CRS .dbf names clipped to 10 chars CRS in a separate, loseable file geopandas convert Single GeoParquet file labels.parquet • geometry as WKB column • CRS embedded as PROJJSON • full-length field names • columnar + compressed one file, self-describing, no sidecars

Prerequisites and Environment

Install the pinned toolchain once. pyarrow provides the Parquet writer that geopandas calls under the hood:

bash
pip install geopandas==0.14.4 pyarrow==16.1.0 shapely==2.0.6 pyproj==3.6.1

Step 1 — Read the Shapefile and Inspect What Arrived

geopandas.read_file resolves the whole sidecar cluster from the single .shp path — it opens the .shx, .dbf, and .prj automatically. Read it, then immediately inspect the CRS and the columns so you know the true state of the data before touching it:

python
from pathlib import Path
import geopandas as gpd


def load_shapefile(shp_path: Path) -> gpd.GeoDataFrame:
    """Load a Shapefile and its sidecars into a single GeoDataFrame."""
    gdf: gpd.GeoDataFrame = gpd.read_file(shp_path)
    print(f"features:   {len(gdf)}")
    print(f"crs:        {gdf.crs}")
    print(f"geom types: {sorted(gdf.geom_type.unique())}")
    print(f"columns:    {list(gdf.columns)}")
    return gdf


gdf = load_shapefile(Path("annotations/parcels.shp"))

If crs prints None, the .prj is missing or unreadable — handle that in Step 2 before anything else. If geom types shows both Polygon and MultiPolygon, note it for Step 4.

Step 2 — Confirm or Assign the CRS

When the .prj parsed cleanly, gdf.crs is a pyproj.CRS object and you only need to confirm it is the projection you expect. When it is None, you must assign the correct authority code — use set_crs, which attaches the CRS without moving any coordinates (that is to_crs, which you do not want here because the coordinates are already in the source projection):

python
from pyproj import CRS


def ensure_crs(gdf: gpd.GeoDataFrame, expected_epsg: int) -> gpd.GeoDataFrame:
    """Confirm the parsed CRS, or assign it if the .prj was missing."""
    if gdf.crs is None:
        # .prj was absent — attach the known CRS without reprojecting.
        gdf = gdf.set_crs(epsg=expected_epsg, allow_override=False)
        print(f"assigned CRS EPSG:{expected_epsg} (was None)")
    else:
        parsed = CRS.from_user_input(gdf.crs)
        if parsed.to_epsg() != expected_epsg:
            raise ValueError(
                f".prj CRS EPSG:{parsed.to_epsg()} != expected EPSG:{expected_epsg}"
            )
        print(f"confirmed CRS EPSG:{parsed.to_epsg()}")
    return gdf


gdf = ensure_crs(gdf, expected_epsg=4326)

Assigning the wrong code when coordinate values disagree with the .prj is its own diagnostic problem; if the numbers look like they belong to a different projection than the file claims, work through fixing legacy Shapefile .prj mismatches before you convert. The first time your annotations pass through code, the authority code should be one you have verified — here [EPSG:4326](https://www.geospatialannotation.com/geospatial-annotation-fundamentals-architecture/coordinate-reference-systems-in-annotation-pipelines/) — not one guessed from the filename.

Step 3 — Repair Truncated and Duplicate Field Names

The .dbf clipped every field name to 10 characters on write. GeoParquet will happily store the full names, but only if you restore them first. Supply an explicit rename map keyed by the truncated name, and guard against the collisions truncation can create:

python
def restore_field_names(
    gdf: gpd.GeoDataFrame,
    rename_map: dict[str, str],
) -> gpd.GeoDataFrame:
    """Rename dbf-truncated columns back to their intended full names."""
    present = {old: new for old, new in rename_map.items() if old in gdf.columns}
    renamed = gdf.rename(columns=present)

    duplicates = renamed.columns[renamed.columns.duplicated()].tolist()
    if duplicates:
        raise ValueError(f"rename produced duplicate columns: {duplicates}")

    over_10 = [c for c in renamed.columns if len(c) > 10 and c != "geometry"]
    print(f"restored {len(present)} name(s); {len(over_10)} now exceed 10 chars")
    return renamed


gdf = restore_field_names(
    gdf,
    rename_map={
        "annotatio_c": "annotation_confidence",
        "annotati_1": "annotation_class",
        "reviewer_i": "reviewer_id",
    },
)

Step 4 — Homogenise Geometry and Write GeoParquet

If Step 1 reported mixed Polygon and MultiPolygon rows, promote everything to MultiPolygon so the geometry column has one homogeneous type that every GeoParquet reader accepts. Then write with to_parquet, which serialises geometry to WKB and embeds the CRS as PROJJSON into the file’s geo metadata:

python
from shapely.geometry.base import BaseGeometry
from shapely.geometry import MultiPolygon


def to_multi(geom: BaseGeometry) -> BaseGeometry:
    """Promote a bare Polygon to MultiPolygon; leave other types unchanged."""
    if geom.geom_type == "Polygon":
        return MultiPolygon([geom])
    return geom


def write_geoparquet(gdf: gpd.GeoDataFrame, out_path: Path) -> Path:
    """Write a single self-describing GeoParquet file with the CRS embedded."""
    if gdf.crs is None:
        raise ValueError("refusing to write GeoParquet without a CRS")

    homogeneous = gdf.copy()
    homogeneous["geometry"] = homogeneous.geometry.apply(to_multi)

    out_path.parent.mkdir(parents=True, exist_ok=True)
    homogeneous.to_parquet(out_path, index=False, compression="zstd")
    print(f"wrote {out_path} ({out_path.stat().st_size / 1024:.1f} KiB)")
    return out_path


out = write_geoparquet(gdf, Path("annotations/parcels.parquet"))

Step 5 — Validate the Roundtrip

A conversion you have not verified is a conversion you cannot trust. Read the GeoParquet back and assert two things: every geometry equals its source geometry, and the CRS matches exactly. Use geom_equals for a topological comparison that tolerates the WKB re-encoding, and compare CRS via authority code:

python
def validate_roundtrip(
    source: gpd.GeoDataFrame,
    parquet_path: Path,
) -> None:
    """Assert geometry and CRS survived the Shapefile to GeoParquet conversion."""
    loaded: gpd.GeoDataFrame = gpd.read_parquet(parquet_path)

    # CRS equality by authority code.
    src_epsg = source.crs.to_epsg()
    dst_epsg = loaded.crs.to_epsg()
    assert src_epsg == dst_epsg, f"CRS changed: {src_epsg} -> {dst_epsg}"

    # Row count and geometry equality.
    assert len(source) == len(loaded), "feature count changed"
    src_geom = source.geometry.apply(to_multi).reset_index(drop=True)
    dst_geom = loaded.geometry.reset_index(drop=True)
    mismatches = int((~src_geom.geom_equals(dst_geom)).sum())
    assert mismatches == 0, f"{mismatches} geometries differ after roundtrip"

    print(f"roundtrip OK: {len(loaded)} features, CRS EPSG:{dst_epsg} preserved")


validate_roundtrip(gdf, out)

When this passes, the GeoParquet is a faithful, self-describing replacement for the four-file bundle and is safe to hand to a training loader or annotation platform.

Shapefile Limitation to GeoParquet Fix

Shapefile limitation GeoParquet fix
CRS lives in a separate .prj that can be lost or edited CRS embedded as PROJJSON in the file’s geo metadata
Attribute names clipped to 10 characters by dBASE Parquet schema keeps full-length column names
Four sidecar files must move together by basename One self-describing file, no sidecars
Row-oriented .dbf read in full for any query Columnar layout reads only the columns needed
No built-in compression Per-column compression (zstd, snappy)
2 GB .dbf / .shp size ceiling No practical single-file size limit

Common Errors and Fixes

ValueError: Cannot write GeoDataFrame with CRS set to None Root cause: the .prj was missing when the Shapefile was read, so gdf.crs is None, and to_parquet refuses to write a projection-less file. Fix: call set_crs(epsg=..., allow_override=False) with the verified authority code in Step 2 before writing — never to_crs, which would move coordinates that are already correct.

Column named annotatio_c instead of annotation_confidence in the output Root cause: the name was truncated to 10 characters by the .dbf on the original write and carried straight through the conversion. Fix: apply an explicit rename map keyed on the truncated names before writing, as in Step 3, and assert no duplicates were produced.

Duplicate column names raised during rename Root cause: two long field names truncated to the same 10-character string in the .dbf, so one row of your rename map cannot uniquely recover them. Fix: inspect the raw .dbf field order to disambiguate which physical column is which, rename positionally, then apply the semantic names — do not trust the clipped strings to be unique.

Geometry column contains mixed geometry types on read by a strict GeoParquet consumer Root cause: the source mixed Polygon and MultiPolygon rows, and the writer recorded a heterogeneous geometry type that a strict reader rejects. Fix: promote every feature to its Multi form with the to_multi helper in Step 4 so the column is homogeneous before to_parquet.

pyarrow.lib.ArrowInvalid: GeoParquet metadata not found when reading back Root cause: the file was written with a plain pandas/pyarrow to_parquet on a de-geometried frame, so the geo metadata key was never added. Fix: keep the object as a GeoDataFrame and use gdf.to_parquet (geopandas), which writes the GeoParquet geo metadata; read it with gpd.read_parquet, not pandas.read_parquet.

This guide is one focused conversion within Spatial Data Formats for ML Annotation, which is itself part of Geospatial Annotation Fundamentals & Architecture.