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

Filter returns 0 rows: diagnosisColumn name correct?Fix the column nameNoYesCase and whitespace match?str.strip() and match the caseNoYesNumbers stored as text?pd.to_numeric() before comparingYesNoCheck for nulls; confirm the values existspatialworkflow.io
Diagnosing a filter that returns zero rows.

Load, then filter on one or several conditions

Filter expressions for a GeoDataFrame and what each one selects. the filters you actually useexpressionselectswatch outgdf[gdf.pop > 1000]numeric comparisonNaN never matchesgdf[gdf.type == "A"]exact stringcase and spaces.isin(["A", "B"])any of a listclearer than or.str.contains("oslo")substringadd case=False.notna()non-missing rowsnot is_empty(a) & (b)both conditionsbrackets requiredPython's and / or do not work on Series — use & and | with brackets spatialworkflow.io
Every row here is one line of code and one classic mistake.

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.

Three filters on Eurostat’s NUTS 3 regions that return the wrong number of rows, and the fix for each. three filters that quietly return the wrong rowsas writtenfixedfix== "Hautes-Alpes"✕0 rows✓1 rowstr.strip() firstcontains("central")✕0 rows✓1 rowcase=Falsetext > "500000"✕360 rows✓327 rowspd.to_numeric()GISCO NUTS 2024: 16 names end in a space; "90000" sorts after "500000" as text spatialworkflow.io
None of them raises; each returns a plausible-looking wrong answer.

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.

Filtering by attribute versus filtering by location. two completely different filtersBy attribute✓gdf[gdf["use"] == "park"]✓reads the columns only✓fast — no geometry touched✓CRS is irrelevantBy location✓gdf[gdf.within(boundary)]✓compares geometry✓needs a spatial index✓CRS must match or it returns nothingA spatial filter across mismatched CRSs returns an empty frame, not an error spatialworkflow.io
Combine them by filtering attributes first — it leaves less geometry to test.

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.crs and reproject with gdf.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 population is stored as text, numeric comparisons may fail or return incorrect results. Check gdf.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.

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.