Automating Shapefile Cleanup with Python

Legacy shapefiles arrive with missing .prj files, self-intersecting rings, and null geometries that crash the first spatial join downstream — this guide builds one reusable function that enforces a coordinate reference system, repairs topology, and prunes bad records before ingestion. It is for anyone scripting a batch pipeline over folders of untrusted vendor data with GeoPandas and Shapely. It sits under Shapefile & GeoJSON Parsing in Geospatial Data Ingestion & Processing Workflows.

Why This Approach / What Goes Wrong

The naive cleanup — open the file in desktop GIS, run "repair geometry", export — is manual, unrepeatable, and invisible to version control. A scripted GeoPandas function makes the whole operation deterministic: the same dirty input always produces the same clean output, and every transformation is auditable in a diff. Three specific failures are what a good cleanup routine has to defend against, and each has a subtle wrong answer.

The shapefile cleanup pipeline as a four-gate data flow A dirty vendor shapefile flows through four gates. Gate 1 aligns the CRS by branching on whether gdf.crs is None: a missing CRS is declared with set_crs (metadata only, coordinates unchanged) while a present-but-wrong CRS is reprojected with to_crs (coordinates change), and both paths converge on EPSG 25832. Gate 2 masks invalid rows with a negated is_valid predicate and repairs them with make_valid. Gate 3 prunes null geometries with dropna and empty geometries with a negated is_empty predicate. Gate 4 renames columns longer than ten characters to avoid silent ESRI truncation. The result is a clean shapefile that is valid, single-CRS, free of null and empty rows, and safe from field-name loss. Dirty vendor .shp missing .prj · self-intersections · null / empty rows 1 · CRS gate — declare vs reproject are not the same operation gdf.crs is None ? crs is None present but wrong set_crs(source_crs) declare · metadata only · no shift to_crs(target_crs) reproject · coordinates change → EPSG:25832 · ETRS89 / UTM 33N 2 · Topology mask = ~is_valid  →  make_valid(row) repairs only invalid rows; may return MultiPolygon 3 · Prune dropna(geometry)  +  keep ~is_empty removes null records and zero-area / zero-length shapes 4 · Rename columns > 10 chars → short names pre-empts silent ESRI 10-character field truncation Clean .shp valid · one CRS · no null / empty · safe field names
The cleanup as four ordered gates. Gate 1 is the crux: a missing CRS is declared with set_crs, a wrong CRS is reprojected with to_crs, and only then do the topology, prune, and rename gates run.

Prerequisites

conda install -c conda-forge "geopandas=0.14.*" "shapely=2.0.*" "pyproj=3.4.*"

Install from conda-forge, not pip, so the GDAL, GEOS, and PROJ C libraries underneath these bindings stay ABI-compatible — the mismatched-DLL failures that plague pip installs are the same ones covered across Shapefile & GeoJSON Parsing.

Step-by-Step Implementation

1. Load the file and gate on the CRS. Read the shapefile, then branch on whether a CRS is present. Assign a known source CRS with set_crs() when it is missing, and reproject with to_crs() when it is present but wrong. The example targets EPSG:25832 (ETRS89 / UTM 33N), a metric projected grid appropriate for area and distance work — never use Web Mercator (EPSG:3857) for measurement.

import warnings
import geopandas as gpd

def load_and_align_crs(input_path: str, source_crs: str, target_crs: str) -> gpd.GeoDataFrame:
    """Load a shapefile and guarantee it lands in target_crs.

    source_crs is the CRS the data is ACTUALLY in — used only when the
    file has no .prj. It never reprojects on its own.
    """
    parcels = gpd.read_file(input_path)
    target_epsg = int(target_crs.split(":")[1])

    if parcels.crs is None:
        warnings.warn(
            f"No .prj found. Declaring source CRS {source_crs} as metadata. "
            "Verify this matches the true projection before trusting coordinates."
        )
        parcels = parcels.set_crs(source_crs)      # declare — no coordinate change

    if parcels.crs.to_epsg() != target_epsg:
        parcels = parcels.to_crs(target_crs)       # reproject — coordinates change

    return parcels

Keep set_crs and to_crs conceptually separate: the first fixes metadata, the second transforms coordinates. Getting this wrong is the most common CRS error on this site — the full treatment is in Coordinate Reference System Transformations and Coordinate Systems with PyProj.

2. Repair invalid topology in place. Build a boolean mask of invalid rows and apply make_valid only to those — running it over already-valid geometries wastes time and can needlessly reorder vertices. make_valid resolves self-intersections and bad ring orientation without discarding data.

What make_valid does to a self-intersecting polygon Two panels compared side by side. On the left, a bow-tie ring stored as a single Polygon crosses itself at one point; is_valid returns False and explain_validity reports a self-intersection. On the right, after make_valid, the same footprint comes back as two triangular parts held in one MultiPolygon: the geometry is now valid, but geom_type has changed from Polygon to MultiPolygon, which breaks any downstream step that assumes single-part features. make_valid repairs the ring — and can change the geometry type before · invalid ring after make_valid · valid part 0 part 1 make_valid self-intersection at the crossing point geom_type: Polygon is_valid → False · explain_validity: Self-intersection two parts wrapped in one MultiPolygon geom_type: MultiPolygon is_valid → True · .explode() splits the parts Validity is restored but the type changed — explode before code that assumes single-part polygons.
Repair changes the geometry class as well as its validity: a self-intersecting Polygon comes back as a two-part MultiPolygon.
from shapely import make_valid   # shapely 2.x top-level import

def repair_topology(parcels: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    invalid_mask = ~parcels.geometry.is_valid
    n_invalid = int(invalid_mask.sum())
    if n_invalid:
        print(f"Repairing {n_invalid} invalid geometries...")
        parcels.loc[invalid_mask, "geometry"] = parcels.loc[
            invalid_mask, "geometry"
        ].apply(make_valid)
    return parcels

3. Prune null and empty features. A null geometry (no .shp record) and an empty geometry (a valid but zero-area/zero-length shape) are different states, and both break renderers and joins. Drop the nulls with dropna, then filter the empties with the vectorized is_empty predicate.

def prune_features(parcels: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    before = len(parcels)
    parcels = parcels.dropna(subset=["geometry"])
    parcels = parcels[~parcels.geometry.is_empty]
    print(f"Dropped {before - len(parcels)} null/empty features")
    return parcels

4. Normalize field names, then export. Rename any column longer than 10 characters before writing to .shp, otherwise ESRI truncation mangles it silently. If you control the downstream stack, prefer writing to a format without the 10-character ceiling — see GeoParquet vs Shapefile for storage.

How ESRI Shapefile field-name truncation destroys columns A four-row table maps source column names to the names actually written into the .dbf file. parcel_id is nine characters and survives unchanged. population_density is cut to populatio_ with no warning. land_use_class and land_use_code both collapse to the same ten-character name, land_use_c, so one of the two columns is overwritten or dropped. A dashed rule marks the ten-character ceiling, and a closing band gives the fix: rename before writing, or use GeoPackage or GeoParquet, which have no name-length limit. The ESRI 10-character field ceiling, applied silently on write 10 chars column in the GeoDataFrame written into the .dbf outcome parcel_id parcel_id kept · already under the ceiling population_density populatio_ truncated, no error raised land_use_class land_use_c same name as the row below land_use_code land_use_c collision · one column is lost Fix: rename before to_file() — population_density → pop_dens, land_use_class → lu_class. Or write GeoPackage / GeoParquet, which have no field-name length ceiling.
Truncation is not just cosmetic: two columns that differ only after the tenth character collapse onto one name, and the loser disappears.
def export_clean(parcels: gpd.GeoDataFrame, output_path: str) -> None:
    rename_map = {
        "population_density": "pop_dens",
        "land_use_classification": "landuse",
    }
    parcels = parcels.rename(columns=rename_map)

    long_names = [c for c in parcels.columns if c != "geometry" and len(c) > 10]
    if long_names:
        warnings.warn(f"Fields will be truncated by ESRI Shapefile: {long_names}")

    parcels.to_file(output_path, driver="ESRI Shapefile")
    print(f"Cleanup complete. {len(parcels)} features saved to {output_path}")

5. Settle the geometry type before the writer does. After make_valid a polygon layer typically contains a mixture of Polygon and MultiPolygon, and repairs on badly-noded input can leave a GeometryCollection holding stray lines or points. Decide explicitly: promote everything to multi-part, which keeps every feature and every attribute row intact, or explode to single parts, which changes the row count and duplicates attributes across the pieces. Promotion is the right default for a cleanup pass, because it is the only option that preserves the one-row-per-record relationship the vendor's identifiers assume.

from shapely.geometry import MultiPolygon
from shapely.geometry.base import BaseMultipartGeometry

def homogenise_geometry(parcels: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Force a single, writable geometry type: MultiPolygon for every row."""
    collections = parcels.geom_type == "GeometryCollection"
    if collections.any():
        # Keep only the polygonal members; lines and points are repair debris
        parcels.loc[collections, "geometry"] = parcels.loc[
            collections, "geometry"
        ].apply(lambda g: MultiPolygon([p for p in g.geoms
                                        if p.geom_type == "Polygon"]))

    parcels["geometry"] = parcels.geometry.apply(
        lambda g: g if isinstance(g, BaseMultipartGeometry) else MultiPolygon([g])
    )
    assert parcels.geom_type.eq("MultiPolygon").all()
    return parcels

6. Coerce the attribute table into what dBASE can actually store. This is the step most cleanup scripts skip, and the one that produces the subtlest downstream bugs. Convert timestamps to dates, widen or stringify oversized integers, map booleans to a two-value integer flag, replace vendor sentinel values with genuine nulls, and strip the trailing spaces that fixed-width .dbf character fields carry.

import numpy as np
import pandas as pd

SENTINELS = ["-9999", "-999", "N/A", "NULL", "", "  "]

def coerce_attributes(parcels: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    for col in parcels.columns:
        if col == "geometry":
            continue
        series = parcels[col]

        if pd.api.types.is_datetime64_any_dtype(series):
            parcels[col] = series.dt.date          # dBASE has no time component
        elif pd.api.types.is_bool_dtype(series):
            parcels[col] = series.astype("int32")  # logical -> 0/1 numeric
        elif pd.api.types.is_integer_dtype(series):
            if series.abs().max() > np.iinfo(np.int32).max:
                parcels[col] = series.astype(str)  # keep every digit; widen later
        elif pd.api.types.is_object_dtype(series):
            parcels[col] = (series.astype("string")
                                  .str.strip()
                                  .replace(SENTINELS, pd.NA))
    return parcels

Coercing before the write means the rules are visible in code and in a diff, rather than being applied invisibly by whichever GDAL version the machine happens to have. Where the downstream consumer is under your control, the better answer is not to coerce at all — write GeoPackage or GeoParquet, which carry real types, and reserve the Shapefile export for the one consumer that demands it.

7. Compose the full pipeline and record what changed. Chain the stages into one entry point you can call over a folder of vendor deliveries. Return a per-file record of the repairs, because "the cleanup ran" is not the same claim as "nothing needed cleaning" — a delivery whose invalid-geometry count jumps from 4 to 4,000 is a supplier problem, and you only see it if the numbers are written down.

from pathlib import Path
import pandas as pd

def clean_shapefile(input_path: str, output_path: str,
                    source_crs: str = "EPSG:25832",
                    target_crs: str = "EPSG:25832") -> dict:
    parcels = load_and_align_crs(input_path, source_crs, target_crs)
    n_read = len(parcels)
    n_invalid = int((~parcels.geometry.is_valid).sum())

    parcels = repair_topology(parcels)
    parcels = prune_features(parcels)
    parcels = homogenise_geometry(parcels)
    parcels = coerce_attributes(parcels)
    export_clean(parcels, output_path)

    return {
        "source": Path(input_path).name,
        "features_in": n_read,
        "features_out": len(parcels),
        "repaired": n_invalid,
        "pruned": n_read - len(parcels),
        "target_epsg": target_crs,
    }

audit = [clean_shapefile(str(shp), f"cleaned/{shp.stem}_clean.shp")
         for shp in sorted(Path("raw_deliveries").glob("*.shp"))]
pd.DataFrame(audit).to_csv("cleaned/_audit.csv", index=False)

Verification

Re-open the written output and assert the three invariants the pipeline promises: the CRS is exactly the target, every geometry is valid, and no null or empty records survived.

import geopandas as gpd

cleaned = gpd.read_file("cleaned/berlin_parcels_clean.shp")

assert cleaned.crs.to_epsg() == 25832, f"Wrong CRS: {cleaned.crs.to_epsg()}"
assert cleaned.geometry.is_valid.all(), "Invalid geometries remain"
assert not cleaned.geometry.is_empty.any(), "Empty geometries remain"
assert cleaned.geometry.notna().all(), "Null geometries remain"

print(f"OK: {len(cleaned)} features, all valid, CRS={cleaned.crs.to_epsg()}")
# OK: 4127 features, all valid, CRS=25832

If all four assertions pass, the file is safe to hand to a spatial join, a PostGIS load, or a web-map tiler.

Edge Cases & Debugging

Frequently Asked Questions

Should I clean the Shapefile in place or convert first and clean afterwards? Convert first whenever the downstream consumer will accept it. Every repair written back to .shp has to survive the 10-character field ceiling, the dBASE type system and the single-geometry-type header, so a good half of this guide is working around the output format rather than fixing the data. Reading the dirty Shapefile and writing a clean GeoPackage or GeoParquet deletes those constraints outright — see GeoParquet vs Shapefile for Storage. Keep the Shapefile export only for the specific consumer that insists on one.

How do I know the source CRS when there is no .prj? You do not, and guessing is what causes the tens-of-metres offsets. Ask the supplier, then corroborate: reproject a sample to EPSG:4326 under each candidate CRS and see which one lands the features on the ground they describe. A national grid mistaken for its neighbouring zone puts the data a few hundred kilometres out, which is obvious on a map and invisible in an assertion. Record the answer in a per-supplier configuration file so the next delivery does not restart the investigation.

Is it safe to run make_valid over every feature instead of masking the invalid ones? It is safe but wasteful, and it is not a no-op. make_valid rebuilds the geometry through the noding engine, so already-valid rings can come back with reordered vertices and, on some GEOS versions, a different ring orientation — enough to make a byte-level diff between two runs useless. Masking on ~is_valid keeps untouched features genuinely untouched, which is what makes the audit trail meaningful.

What should happen to features the pipeline cannot repair? Quarantine, never drop. Write the failing rows to a side file with their original index and the exception text, keep the batch running, and count them in the audit record. Silently discarding a handful of features per delivery is how a parcel layer ends up 0.3% short of the register with nobody able to say when it happened.

Does this pipeline scale to a nightly job over hundreds of deliveries? Yes, with two changes. Parallelise across files rather than within them — each delivery is independent, so a ProcessPoolExecutor over the file list is the whole story — and make the run idempotent by writing each output to a temporary name and moving it into place only on success, so an interrupted run leaves no half-written .shp for the next stage to pick up.