How to Remove Null and Empty Geometries in GeoPandas

On this page

Problem statement

A GeoDataFrame can contain unusable geometry rows in more ways than it looks:

  • null geometries: missing values such as None
  • empty geometries: geometry objects that exist but contain no coordinates
  • NaN geometries: points built from missing coordinates, which are neither of the above

This is a common cleanup step in GIS workflows. You may see these rows after:

  • reading shapefiles or GeoJSON files with incomplete features
  • creating geometries from coordinates where some rows had none
  • running intersection(), clip(), overlay(), or similar operations that return empty results

If you do not remove these rows, they cause inconsistent behaviour or unnecessary errors in spatial joins, overlays, plotting, exports, and downstream validation. The examples below use two real sources of bad rows: the Food Standards Agency's register of Rutland's 462 food businesses, 124 of which have no coordinates, and Rutland's buildings intersected with one ward.

Quick answer

Filter both conditions on the geometry column β€” and if the geometries were built from coordinates, check for NaN coordinates too:

clean_gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty].copy()
print(len(gdf), "->", len(clean_gdf), "rows")

# built from x/y columns? a missing coordinate makes a POINT (NaN NaN): not null, not empty
clean_gdf = clean_gdf[shapely.is_valid(clean_gdf.geometry.values) | (clean_gdf.geom_type != "Point")]
print("after dropping NaN points:", len(clean_gdf), "rows")
clean_gdf = clean_gdf.reset_index(drop=True)
462 -> 338 rows
after dropping NaN points: 338 rows
  • gdf.geometry.notna() removes null geometries
  • ~gdf.geometry.is_empty removes empty geometries
  • the validity check removes points whose coordinates are NaN, which the first two let straight through

Null vs empty: why notna alone is not enough

Null vs empty geometrygeometry is None?Drop it (null geometry)YesNogeometry is_empty?Drop it (empty geometry)YesNoKeep the rowspatialworkflow.io
Why removing nulls needs both a None check and an is_empty check.

Find null, empty and NaN geometries, then remove them

Null, empty and valid geometries compared across the tests that find them. three states, three different testsstateisna()is_emptysurvives notna()?None / missingTrueFalseno β€” removedPOLYGON EMPTYFalseTrueyes β€” slips throughPOINT (NaN NaN)FalseFalseyes β€” slips past bothvalid geometryFalseFalseyes β€” correctnotna() alone keeps every empty geometry; NaN points need a third testAn empty geometry is a real object that happens to contain nothing spatialworkflow.io
This one distinction explains most "I already filtered those out" surprises.

Inspect the GeoDataFrame for null geometries

In GeoPandas, a null geometry is a missing value in the geometry column, usually None. Use isna() to find them:

print(f"Null geometries: {gdf.geometry.isna().sum()}")
print(gdf.loc[gdf.geometry.isna(), ["FHRSID", "BusinessName"]].head(3))
Null geometries: 124
     FHRSID                    BusinessName
7   1153840                   Ali Green Ltd
9   1989711               All You Can Treat
13  1955017  Anna’s Little Rays Of Sunshine

The FSA register lists premises with no location β€” mobile caterers, home businesses, a few addresses the agency never geocoded β€” and GeoJSON stores each of them with "geometry": null. That is what read_file turned into None.

Inspect the GeoDataFrame for empty geometries

An empty geometry is different: a geometry object with no coordinates. They typically come out of spatial operations β€” here, every building in Rutland intersected with the Oakham South ward:

clipped = buildings.copy()
clipped["geometry"] = buildings.geometry.intersection(oakham_south)
print(f"{len(clipped):,} buildings; empty after the intersection: {int(clipped.geometry.is_empty.sum()):,}; "
      f"null: {int(clipped.geometry.isna().sum())}")
print(clipped.geometry[clipped.geometry.is_empty].iloc[0])
5,513 buildings; empty after the intersection: 5,332; null: 0
POLYGON EMPTY

Every building outside the ward became POLYGON EMPTY, not None β€” so a notna() filter keeps all of them. (gpd.clip would have dropped them for you; intersection does not.)

The third kind: NaN coordinates

Build the points yourself from coordinate columns, as you do with any CSV or API, and a missing coordinate becomes neither:

x = pd.to_numeric(records["Geocode.Longitude"], errors="coerce")
y = pd.to_numeric(records["Geocode.Latitude"], errors="coerce")
built = gpd.GeoDataFrame(records[["FHRSID", "BusinessName"]], geometry=gpd.points_from_xy(x, y), crs=4326)
built_all = built.copy()
print(f"missing coordinates: {int(x.isna().sum())} | isna: {int(built.geometry.isna().sum())} | "
      f"is_empty: {int(built.geometry.is_empty.sum())}")
print(built.geometry[x.isna()].iloc[0], "|", shapely.is_valid_reason(built.geometry[x.isna()].iloc[0]))
missing coordinates: 124 | isna: 0 | is_empty: 0
POINT (NaN NaN) | Invalid Coordinate[nan nan]

points_from_xy made 124 POINT (NaN NaN) objects. They are not null and not empty, both filters keep them, and they will crash or silently vanish later β€” in a reprojection, a spatial index, a plot. Validity catches them (Invalid Coordinate), and so does checking the coordinate columns before building the points, which is better still.

Remove all three

before = len(built)
built = built[built.geometry.notna() & ~built.geometry.is_empty]
print("after notna & ~is_empty:", len(built), "of", before)
built = built[~(built.geom_type.eq("Point") & built.geometry.x.isna())]
print("after dropping NaN points:", len(built))
after notna & ~is_empty: 462 of 462
after dropping NaN points: 338

Reset the index after filtering

After removing rows, the original index has gaps. Resetting it makes exports and later processing cleaner:

built = built.reset_index(drop=True)
print(built.index[:3].tolist(), "...", built.index[-1])
[0, 1, 2] ... 337

Confirm the cleaned result

for name, frame in (("register, cleaned", clean_gdf), ("points built from x/y", built)):
    print(f"{name:22} rows {len(frame):>3} | null {int(frame.geometry.isna().sum())} | "
          f"empty {int(frame.geometry.is_empty.sum())} | invalid {int((~frame.geometry.is_valid).sum())}")
register, cleaned      rows 338 | null 0 | empty 0 | invalid 0
points built from x/y  rows 338 | null 0 | empty 0 | invalid 0

Missing geometries in Rutland's food register and buildings

The data used on this page

The Food Standards Agency's food hygiene rating data for Rutland (Open Government Licence), saved once as GeoJSON exactly as the API describes each premises; Rutland's buildings from Geofabrik's OpenStreetMap extract of 1 January 2026 (Β© OpenStreetMap contributors, ODbL); and the Oakham South ward from the ONS December 2024 boundaries (OGL).

import json
from pathlib import Path

import geopandas as gpd
import pandas as pd
import requests
import shapely

Path("data").mkdir(exist_ok=True)
session = requests.Session()
if not Path("data/food.json").exists():
    Path("data/food.json").write_bytes(session.get(
        # the FSA register file for Rutland as saved on 9 October 2026 (updated daily); live:
        # ratings.food.gov.uk/api/open-data-files/FHRS879en-GB.json
        "https://data.spatialworkflow.io/fhrs-879-2026-10-09.json", timeout=120).content)
    Path("data/rutland.shp.zip").write_bytes(session.get(
        "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
    Path("data/oakham_south.geojson").write_bytes(session.get(
        "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
        "Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
        params={"where": "WD24NM='Oakham South'", "outFields": "WD24NM", "outSR": 4326, "f": "geojson"}, timeout=120).content)

found = json.loads(Path("data/food.json").read_text())["FHRSEstablishment"]["EstablishmentCollection"]
records = pd.json_normalize(found if isinstance(found, list) else found["EstablishmentDetail"])
features = [{"type": "Feature",
             "properties": {"FHRSID": int(r["FHRSID"]), "BusinessName": r["BusinessName"]},
             "geometry": (None if pd.isna(pd.to_numeric(r.get("Geocode.Longitude"), errors="coerce"))
                          else {"type": "Point", "coordinates": [float(r["Geocode.Longitude"]), float(r["Geocode.Latitude"])]})}
            for r in records.to_dict("records")]
Path("data/food.geojson").write_text(json.dumps({"type": "FeatureCollection", "features": features}))

gdf = gpd.read_file("data/food.geojson")
buildings = gpd.read_file("/vsizip/data/rutland.shp.zip/gis_osm_buildings_a_free_1.shp")
oakham_south = gpd.read_file("data/oakham_south.geojson").geometry.iloc[0]
print(len(gdf), "food businesses |", len(buildings), "buildings")
462 food businesses | 5513 buildings

Example 1: Count every kind before you filter

def geometry_audit(frame):
    geoms = frame.geometry
    has_geom = geoms.notna()
    return {"rows": len(frame), "null": int((~has_geom).sum()), "empty": int(geoms.is_empty.sum()),
            "invalid (incl. NaN)": int((has_geom & ~geoms.is_empty & ~geoms.is_valid).sum())}

print("register as GeoJSON:", geometry_audit(gdf))
print("buildings x ward:   ", geometry_audit(clipped))
register as GeoJSON: {'rows': 462, 'null': 124, 'empty': 0, 'invalid (incl. NaN)': 0}
buildings x ward:    {'rows': 5513, 'null': 0, 'empty': 5332, 'invalid (incl. NaN)': 0}

Example 2: The formats do not agree on what a missing geometry is

The same 124 unlocated premises, written as POINT EMPTY and as None, then read back:

as_empty = built_all.copy()
as_empty.loc[as_empty.geometry.x.isna(), "geometry"] = shapely.Point()
as_none = built_all.copy()
as_none.loc[as_none.geometry.x.isna(), "geometry"] = None
for driver, suffix in (("GPKG", "gpkg"), ("GeoJSON", "geojson"), ("ESRI Shapefile", "shp")):
    for label, frame in (("empty", as_empty), ("None", as_none)):
        frame[["FHRSID", "geometry"]].to_file(f"data/round_{label}.{suffix}", driver=driver)
        back = gpd.read_file(f"data/round_{label}.{suffix}")
        print(f"{driver:15} wrote {label:5} -> read {int(back.geometry.isna().sum()):>3} null, "
              f"{int(back.geometry.is_empty.sum()):>3} empty")
GPKG            wrote empty -> read   0 null, 124 empty
GPKG            wrote None  -> read 124 null,   0 empty
GeoJSON         wrote empty -> read 124 null,   0 empty
GeoJSON         wrote None  -> read 124 null,   0 empty
ESRI Shapefile  wrote empty -> read 124 null,   0 empty
ESRI Shapefile  wrote None  -> read 124 null,   0 empty

GeoPackage kept the distinction. GeoJSON and shapefile have no way to write an empty point, and both came back as nulls. So the filter has to cover both kinds whichever way a file arrived β€” and "no empties in my data" can mean "the last format it passed through turned them into nulls".

Example 3: Clean, repair, and clean again

parcel = shapely.from_wkt("POLYGON ((0 0, 1 1, 2 2, 0 0))")          # a zero-area outline
print("valid:", parcel.is_valid, "| buffer(0):", shapely.buffer(parcel, 0), "| make_valid:", shapely.make_valid(parcel))
valid: False | buffer(0): POLYGON EMPTY | make_valid: MULTILINESTRING ((0 0, 1 1), (1 1, 2 2))

A repair can create the very thing you filtered out: buffer(0) turns a zero-area polygon into POLYGON EMPTY, and make_valid turns it into lines. Filter nulls and empties again after any repair, and check the geometry types while you are there.

Three ways a geometry can be missing

The key point is that null, empty and NaN are three different conditions.

The order of geometry cleaning filters. the order that leaves a clean framedrop nullsnotna()drop empties~is_emptyrepair invalidmake_valid()drop newemptiesrepairs can emptyreset_indexdrop=Truebuffer(0) turned a zero-area outline into POLYGON EMPTY; make_valid() turned it into lines spatialworkflow.io
The fourth stage is the one that is almost always missing.
  • isna() checks for missing values in the geometry column, such as None
  • is_empty checks for geometry objects that exist but have no coordinates
  • a point with NaN coordinates is neither; only validity, or checking the coordinates, finds it

That is why dropna() or geometry.notna() alone is not enough for many datasets. A row with an empty polygon is not null, so it stays unless you also filter ~geometry.is_empty β€” and in GeoPandas 1.x, is_empty on a None simply returns False, so the combined filter is safe in one line.

In practice, removing all three is a good default before sjoin(), overlay(), clip(), plotting, and writing cleaned outputs for other GIS tools.

This cleanup is also different from geometry validity. A polygon can be non-null and non-empty but still invalid because of self-intersections. Removing missing and empty rows is usually the first cleanup step, not the last one.

When to keep empty geometries

Null and empty geometries are not the same

Do not assume one check covers both cases. The intersection above produced thousands of empties and not a single null; the FSA GeoJSON produced 124 nulls and not a single empty.

Some workflows may keep empty results temporarily

After an intersection or overlay, empty geometries may be expected as an intermediate result. That is fine, but remove them before final export or downstream analysis.

Geometry validity is separate

A geometry can still be invalid after this cleanup. If you need topology-safe data, check validity next β€” and filter again after repairing.

CRS issues

Removing null and empty geometries does not change the CRS, but confirm it before further analysis, and reproject to a projected CRS before distance or area calculations.

Watch for export behaviour

Formats disagree about missing geometry, as example 2 showed: an empty point survives a GeoPackage and becomes a null in GeoJSON and shapefile. Clean before writing, so the output means the same thing in every format.

Common pitfall

dropna() on the full DataFrame may remove rows for non-geometry nulls you want to keep. If your goal is only geometry cleanup, target the geometry column directly.

For the broader background on how GeoPandas works with spatial data, see How to Reproject Spatial Data in Python (GeoPandas).

If you are working with input and output files around this cleanup step, read How to Read a Shapefile in Python with GeoPandas and How to Export GeoJSON in Python with GeoPandas.

If this cleanup is part of a failing file workflow, start with How to Read a Shapefile in Python with GeoPandas to verify the layer loaded correctly.

FAQ

What is the difference between null and empty geometries in GeoPandas?

A null geometry is a missing value such as None. An empty geometry is a real geometry object with no coordinates. Use isna() for null values and is_empty for empty geometry objects.

Do I need to remove both None and empty geometries before spatial joins?

Usually, yes. Spatial joins, overlays, and plotting are more reliable when the geometry column contains only usable geometries.

Why does isna() not catch empty geometries?

Because empty geometries are not missing values. They are geometry objects, so they are not considered null by pandas or GeoPandas β€” and neither is a point whose coordinates are NaN.

Can I save a shapefile or GeoJSON if some geometries are null?

Yes β€” all three formats wrote the 124 unlocated food businesses without complaint. But they did not agree: an empty point written to GeoJSON or a shapefile came back as a null, while a GeoPackage kept it empty. Cleaning first makes the output mean the same thing in every format.

How do I count how many rows have null versus empty geometries before filtering?

Count them separately, because they are different conditions: gdf.geometry.isna().sum() gives null geometries and gdf.geometry.is_empty.sum() gives empty ones β€” in GeoPandas 1.x is_empty returns False for a None, so no pre-filter is needed. For points built from coordinates, count gdf.geometry.x.isna() as well, since a POINT (NaN NaN) is neither.

Can I remove null and empty geometries in a single line?

Yes: gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty]. It does not catch points with NaN coordinates, which need ~gdf.geometry.x.isna() or a validity check as well.

Does removing empty geometries also fix invalid geometries?

No. An invalid geometry, such as a self-intersecting polygon, is non-null and non-empty, so these filters keep it. Check validity separately with gdf.is_valid and repair with make_valid() β€” then filter again, because a repair can itself produce empties (buffer(0) turned a zero-area polygon into POLYGON EMPTY) or change the geometry type.

Why does my exported shapefile still seem to contain empty geometry rows?

You likely exported before filtering, or wrote a different GeoDataFrame than the cleaned one. Clean first, confirm with clean_gdf.geometry.is_empty.sum() == 0, then call clean_gdf.to_file(...); remember the filter returns a new object you must save.