GeoPandas Fundamentals

What a GeoDataFrame adds on top of Pandas, what a coordinate reference system actually is, and how a spatial join assigns points to the polygon they fall inside.

Most of the data work covered elsewhere on this site treats location as just another column — a city name, a ZIP code, a country string. GeoPandas exists for the moment that stops being enough: when "is this point inside that area" or "how far apart are these two things" is itself the question a row has to answer.

This article covers the two ideas everything else in this module builds on: what a GeoDataFrame actually is, and how a spatial join matches rows by geometry instead of by equal values.

A GeoDataFrame is a DataFrame with two extras#

Python
import geopandas as gpd
stores = gpd.read_file("stores.geojson")territories = gpd.read_file("territories.geojson")

gpd.read_file returns a GeoDataFrame — everything a Pandas DataFrame already gives you, plus a geometry column holding actual shapes (points, lines, polygons), and a coordinate reference system attached to the whole layer. Once geometry is in a normal column, grouping, filtering, and aggregating it is ordinary Pandas — GeoPandas only adds the geometric operations Pandas has no concept of.

What a coordinate reference system is#

A coordinate reference system (CRS) defines what the numbers in a geometry actually mean: which point on Earth is (0, 0), what unit the coordinates are in, and how a curved planet gets flattened onto a plane. EPSG:4326 — plain latitude/longitude in degrees — and a local projected system measured in meters are both common, and a coordinate pair means something completely different under each one.

Two layers can only be compared or joined spatially once they agree on a CRS. Loaded from different sources, stores and territories might not:

Python
stores = stores.to_crs(territories.crs)

to_crs reprojects every geometry in stores into territories's system. Which layer you reproject into which does not matter, as long as both end up the same — what matters is doing it before anything that compares their geometries, because a mismatch does not always raise an error. Depending on the library version it can warn, or silently return wrong matches.

Joining by geometry instead of by key#

A spatial join answers a geometric question the way merge answers a value-equality question — instead of matching rows where a key column is equal, it matches rows where a geometric predicate holds between two geometries:

Python
matched = gpd.sjoin(    stores,    territories[["territory_id", "geometry"]],    how="left",    predicate="within")

predicate="within" keeps every store whose point falls inside a territory polygon and attaches that territory's id to the row. how="left" keeps stores that fall outside every polygon too — their territory id comes back null instead of the row disappearing, which is exactly what you want for spotting bad data later: a null match after a spatial join usually means a gap in the polygons or a CRS mismatch, not a store that genuinely belongs nowhere.

What happens after the join is not new

Once matched has a territory_id column, summarizing it — counts per territory, sums, averages — is the same groupby().agg() pattern used throughout the Python section. Geometry only does the matching; everything downstream of it is ordinary tabular work.

Other predicates#

within is one of several binary predicates GeoPandas exposes through Shapely. The common ones:

PredicateTrue when
withinthe left geometry is entirely inside the right one
containsthe reverse of within
intersectsthe geometries share any point at all (touching counts)
crossesthe geometries cross without one containing the other

within and intersects cover the large majority of point-in-polygon work; reach for the others only when the relationship genuinely calls for them.

Common mistakes#

Joining before reprojecting#

sjoin on two layers in different coordinate systems can fail loudly, warn, or silently produce nonsense matches, depending on the GeoPandas version. Always call to_crs first and treat an all-null result as a hint to check the CRS before anything else.

Assuming a null match means bad data#

With how="left", an unmatched row is expected, not an error — it is how you find points outside every polygon. Switching to how="inner" to "clean up" the output silently deletes exactly the rows worth investigating.

Reaching for intersects by default#

intersects also matches geometries that merely touch at an edge. For "is this point inside this area," within is the more precise choice; use intersects when a boundary touch should genuinely count as a match.

Further reading#

See also#