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_emptyremoves 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
Find null, empty and NaN geometries, then remove them
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.
isna()checks for missing values in the geometry column, such asNoneis_emptychecks 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.
Internal links
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.