How to Filter Spatial Data in Python Using GeoPandas
On this page
Problem statement
Of Rutland's 2,405 OpenStreetMap land use polygons, 159 are residential, and keeping just those is one line of boolean indexing. The traps are in the values: a name with a trailing space, or a case-sensitive contains, filters to zero rows without an error.
In GeoPandas, this usually means filtering a GeoDataFrame by attribute values. Typical cases include:
- keeping features where one column matches a value
- applying multiple conditions
- filtering text fields by exact or partial matches
- removing rows with null values before mapping or analysis
The goal is to subset spatial data without losing the geometry column, so the result is still ready for plotting, exporting, spatial joins, or clipping.
Quick answer
To filter GeoPandas data, use the same boolean indexing pattern you would use with pandas. A filtered GeoDataFrame keeps its geometry column.
import geopandas as gpd
gdf = gpd.read_file("data/landuse.shp")
residential = gdf[gdf["landuse"] == "residential"].copy()
print(type(residential).__name__, len(residential), "of", len(gdf))
GeoDataFrame 159 of 2405
residential is still a GeoDataFrame, so you can save it, map it, or use it in later GIS steps.
Diagnosing a filter that returns 0 rows
Load, then filter on one or several conditions
Load spatial data into a GeoDataFrame
Read a shapefile or GeoJSON with GeoPandas:
import geopandas as gpd
# Shapefile
gdf = gpd.read_file("data/landuse.shp")
# Or GeoJSON
# gdf = gpd.read_file("data/districts.geojson")
Before filtering, inspect the columns and a few rows:
print(gdf.columns.tolist())
print(gdf.head(3))
print(gdf["landuse"].value_counts().head(8).to_dict())
['osm_id', 'landuse', 'name', 'geometry']
osm_id ... geometry
0 3554658 ... POLYGON ((-0.69395 52.6626, -0.69361 52.66254,...
1 3554659 ... POLYGON ((-0.70015 52.65614, -0.69918 52.65597...
2 3700148 ... POLYGON ((-0.74126 52.66536, -0.74106 52.6656,...
[3 rows x 4 columns]
{'forest': 1247, 'grass': 323, 'farmland': 241, 'residential': 159, 'scrub': 93, 'farmyard': 90, 'park': 40, 'meadow': 37}
This helps you confirm:
- the exact column names
- whether a field is text or numeric
- how values are spelled and capitalized
The land use layer here has osm_id, landuse and name. The second layer used below, districts.geojson, has district_name, country, region, population, status and area_sqkm.
Filter by a single column value
To keep only features where one attribute matches a value:
residential = gdf[gdf["landuse"] == "residential"]
print(type(residential))
print(len(residential))
<class 'geopandas.geodataframe.GeoDataFrame'>
159
If you want a clean standalone result for later editing or export, use .copy():
residential = gdf[gdf["landuse"] == "residential"].copy()
Filter with multiple conditions
Use & for AND, and wrap each condition in parentheses:
districts = gpd.read_file("data/districts.geojson")
filtered = districts[
(districts["country"] == "DE")
& (districts["population"] > 500_000)
].copy()
print(len(filtered), "German districts over 500,000:", sorted(filtered["district_name"])[:4], "…")
21 German districts over 500,000: ['Berlin', 'Bremen, Kreisfreie Stadt', 'Dortmund, Kreisfreie Stadt', 'Dresden, Kreisfreie Stadt'] …
Use | for OR:
green_areas = gdf[
(gdf["landuse"] == "park")
| (gdf["landuse"] == "forest")
].copy()
If you need several values from one column, isin() is usually cleaner:
selected = gdf[gdf["landuse"].isin(["park", "forest", "meadow"])].copy()
print(len(green_areas), "parks or forests;", len(selected), "parks, forests or meadows")
1287 parks or forests; 1324 parks, forests or meadows
Be careful with parentheses. This is correct:
subset = districts[
(districts["region"] == "Ile-de-France")
& (districts["status"] == "p")
]
print(len(subset), "Ile-de-France districts with a provisional population")
8 Ile-de-France districts with a provisional population
This is not — & binds more tightly than ==, so Python tries to evaluate "Ile-de-France" & districts["status"] first:
try:
districts[districts["region"] == "Ile-de-France" & districts["status"] == "p"]
except TypeError as error:
print("TypeError:", str(error)[:80])
TypeError: operation 'rand_' not supported for dtype 'str' with object of type <class 'str'
Rutland land use and NUTS 3 districts filtered
The data used on this page
Two real layers. landuse.shp is OpenStreetMap land use for Rutland from Geofabrik's extract of 1 January 2026: 2,405 polygons with a landuse class and, occasionally, a name. districts.geojson is the 1,345 NUTS 3 regions of Europe from Eurostat's GISCO boundaries (2024, 1:20 million), with the name as it is stored, the country code, the NUTS 1 region's name, the population on 1 January 2024 from Eurostat table demo_r_pjanaggr3, Eurostat's flag on that figure as status, and the area in km².
import io
import os
import zipfile
import geopandas as gpd
import pandas as pd
import requests
os.makedirs("data", exist_ok=True)
os.makedirs("output", exist_ok=True)
if not os.path.exists("data/landuse.shp"):
osm = requests.get("https://download.geofabrik.de/europe/united-kingdom/england/"
"rutland-260101-free.shp.zip", timeout=300).content
zipfile.ZipFile(io.BytesIO(osm)).extractall("osm")
lu = gpd.read_file("osm/gis_osm_landuse_a_free_1.shp")
lu.rename(columns={"fclass": "landuse"})[["osm_id", "landuse", "name", "geometry"]].to_file(
"data/landuse.shp")
if not os.path.exists("data/districts.geojson"):
gisco = ("https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/"
"NUTS_RG_20M_2024_4326_LEVL_{}.geojson")
nuts3, nuts1 = gpd.read_file(gisco.format(3)), gpd.read_file(gisco.format(1))
csv = requests.get("https://ec.europa.eu/eurostat/api/dissemination/sdmx/2.1/data/"
"demo_r_pjanaggr3/A.NR.T.TOTAL.", timeout=180,
params={"format": "SDMX-CSV", "startPeriod": "2024", "endPeriod": "2024"},
headers={"User-Agent": "spatialworkflow.io guide"}).text
pop = pd.read_csv(io.StringIO(csv)).set_index("geo")
districts = gpd.GeoDataFrame({
"nuts_id": nuts3["NUTS_ID"], "district_name": nuts3["NAME_LATN"], "country": nuts3["CNTR_CODE"],
"region": nuts3["NUTS_ID"].str[:3].map(nuts1.set_index("NUTS_ID")["NAME_LATN"]),
"population": nuts3["NUTS_ID"].map(pop["OBS_VALUE"]),
"status": nuts3["NUTS_ID"].map(pop["OBS_FLAG"]),
"area_sqkm": nuts3.to_crs(3035).area / 1e6}, geometry=nuts3.geometry, crs=4326)
districts.to_file("data/districts.geojson")
for name in ("landuse.shp", "districts.geojson"):
layer = gpd.read_file("data/" + name)
print(f"{name:18} {len(layer):>5,} features, columns {[c for c in layer.columns if c != 'geometry']}")
landuse.shp 2,405 features, columns ['osm_id', 'landuse', 'name']
districts.geojson 1,345 features, columns ['nuts_id', 'district_name', 'country', 'region', 'population', 'status', 'area_sqkm']
Filter text fields
Match an exact text value:
district = districts[districts["district_name"] == "Hautes-Alpes"].copy()
print(len(district), "rows for 'Hautes-Alpes';",
len(districts[districts["district_name"].str.strip() == "Hautes-Alpes"]), "after strip()")
print((districts["district_name"] != districts["district_name"].str.strip()).sum(),
"names end in a space, e.g.", districts.loc[districts["district_name"].str.endswith(" "),
"district_name"].head(3).map(repr).tolist())
0 rows for 'Hautes-Alpes'; 1 after strip()
16 names end in a space, e.g. ["'Hautes-Alpes '", "'Martinique '", "'Mayotte '"]
That is the classic zero-row filter, in an official file: Eurostat's 2024 NUTS boundaries store 16 names with a trailing space. Strip text columns once, after reading, and exact matches behave.
For partial matches, use .str.contains():
central_matches = districts[
districts["district_name"].str.contains("central", na=False)
].copy()
To ignore case differences:
central_matches_any = districts[
districts["district_name"].str.contains("central", case=False, na=False)
].copy()
mitte = districts[districts["district_name"].str.contains("mitte", case=False, na=False)]
print(len(central_matches), "case-sensitive;", central_matches_any["district_name"].tolist(),
"case-insensitive;", mitte["district_name"].tolist())
0 case-sensitive; ['Alentejo Central'] case-insensitive; ['Mittelburgenland', 'Mittelsachsen', 'Potsdam-Mittelmark']
This helps when values vary like Central, CENTRAL, or central district.
Filter numeric fields
Keep values above or below a threshold:
large_areas = districts[districts["area_sqkm"] > 10_000].copy()
small_areas = districts[districts["area_sqkm"] < 100].copy()
Filter within a numeric range:
medium_pop = districts[
(districts["population"] >= 100_000)
& (districts["population"] <= 500_000)
].copy()
print(len(large_areas), "over 10,000 km²;", len(small_areas), "under 100 km²;",
len(medium_pop), "with 100,000–500,000 people")
106 over 10,000 km²; 63 under 100 km²; 824 with 100,000–500,000 people
If a numeric column was read as text, convert it first. Text does not refuse the comparison; it compares character by character:
import pandas as pd
as_text = districts["population"].astype("Int64").astype(str) # as a CSV read with dtype=str
print((as_text > "500000").sum(), "rows 'over 500000' as text;",
(pd.to_numeric(as_text, errors="coerce") > 500_000).sum(), "as numbers")
360 rows 'over 500000' as text; 327 as numbers
As text, "90000" is greater than "500000" because 9 sorts after 5.
Filter rows with missing or empty values
Find null values:
missing_status = districts[districts["status"].isna()].copy()
Exclude incomplete records:
cleaned = districts[districts["status"].notna()].copy()
print(len(missing_status), "with no flag;", len(cleaned), "flagged",
cleaned["status"].value_counts().to_dict())
1202 with no flag; 143 flagged {'p': 101, 'e': 42}
status is Eurostat's observation flag: p provisional, e estimated, and no flag for a final figure. A null here means two different things — a final figure for 1,183 regions, and no figure at all for the 19 whose population is also null — so check the column it describes before treating a null flag as "final".
If the data uses empty strings instead of true nulls, check both:
named = gdf[
gdf["name"].notna()
& (gdf["name"].str.strip() != "")
].copy()
print(len(named), "named land-use areas;", gdf["name"].isna().sum(), "null,",
(gdf["name"] == "").sum(), "empty strings")
240 named land-use areas; 2165 null, 0 empty strings
A shapefile cannot tell an empty string from a missing value, and pyogrio reads both as null — which is why the check for "" finds none here.
Export the filtered result
Create a filtered output:
export_gdf = gdf[
(gdf["landuse"] == "residential")
& gdf["name"].notna()
].copy()
print(len(export_gdf), "named residential areas")
11 named residential areas
Save it as a shapefile or GeoJSON:
export_gdf.to_file("output/north_residential.shp")
export_gdf.to_file("output/north_residential.geojson", driver="GeoJSON")
Boolean filters keep the geometry
A GeoDataFrame behaves like a pandas DataFrame with an added geometry column. When you filter rows using boolean conditions, GeoPandas keeps the geometry for the matching records.
That means this works like standard pandas filtering:
gdf[gdf["column"] == value]
but the result remains spatial.
Filtering rows does not change the geometry itself. It only decides which features stay in the output.
For example:
- filtering by
landuse == "park"keeps only park features - buffering changes geometry shapes
- clipping changes geometry extent
So in this page, filtering means attribute filtering, not geometry-based predicates like within() or intersects().
In real GIS workflows, attribute filtering is often the first step before:
- spatial joins
- clipping
- plotting
- exporting
- dissolving
Reducing the dataset early makes later steps faster and easier to validate.
Column names with spaces, types and empty results
- CRS issues: Attribute filtering does not depend on CRS, but if you filter and then run spatial operations, make sure the layer uses the expected CRS. Check with
gdf.crsand reproject withgdf.to_crs(...)if needed. - Invalid geometries: Filtering by attributes does not fix bad geometry. If later steps fail, inspect invalid features with
~gdf.is_valid. - Column names with spaces: Use bracket syntax like
gdf["land use"], not attribute-style access. - Values with spaces: 16 names in Eurostat's own NUTS 2024 file end in a trailing space.
str.strip()text columns after reading. - String versus numeric data types: If
populationis stored as text, numeric comparisons may fail or return incorrect results. Checkgdf.dtypes. - Empty filter results: If your result has zero rows, verify spelling, case, null values, and data type mismatches.
- Large files and memory: Filtering large layers usually means loading the dataset into memory first with
read_file(). For very large datasets, consider using more scalable storage formats or preprocessing steps before loading data into GeoPandas.
Internal links
- How to Read a Shapefile in Python with GeoPandas
- How to Export GeoJSON in Python with GeoPandas
- How to Reproject Spatial Data in Python (GeoPandas)
FAQ
Can I filter a shapefile directly with GeoPandas?
Yes. Read the shapefile with gpd.read_file(), filter the resulting GeoDataFrame, then save the filtered result with .to_file().
How do I filter multiple values in one column?
Use isin():
subset = gdf[gdf["landuse"].isin(["park", "forest", "residential"])].copy()
This is usually cleaner than chaining multiple OR conditions.
Why does my GeoPandas filter return no rows?
Common causes are:
- wrong column name
- case mismatch in text values
- extra spaces in strings
- numeric values stored as text
- null values in the field being filtered
Check gdf.columns, gdf.dtypes, and gdf.head() first.
Does filtering remove the geometry column?
No. Filtering rows keeps the geometry column intact, so the output is still a GeoDataFrame.
How do I filter a GeoDataFrame by bounding box without loading the whole file?
Pass a bbox tuple to gpd.read_file, for example gpd.read_file("data.gpkg", bbox=(minx, miny, maxx, maxy)). This filters at read time so only intersecting features load, which is much faster for large files. The bbox coordinates must be in the file's CRS.
What is the difference between attribute filtering and a spatial predicate filter?
Attribute filtering selects rows by column values, while a spatial predicate filter selects rows by their relationship to other geometry. For the spatial case use gpd.sjoin(gdf, other, predicate="intersects") (the keyword is predicate=, not the old op=).
How do I filter for features that fall inside one polygon?
Use a clip or a spatial join against that polygon. For a true subset of features, gpd.sjoin(gdf, boundary, predicate="within") keeps features located inside it, while gpd.clip(gdf, boundary) also trims geometry at the boundary edge.
Why does my filtered GeoDataFrame trigger a SettingWithCopyWarning when I edit it?
The warning appears because the filtered result is a view into the original frame. Add .copy() after filtering, as in subset = gdf[gdf["type"] == "park"].copy(), so later column edits operate on an independent object.