What Is a GeoDataFrame? Structure and Key Concepts
On this page
Problem statement
A GeoDataFrame is a pandas table with an active geometry column and a coordinate reference system β and loading one is easy enough that people skip finding out what is in it. The questions worth answering first:
- what makes it different from a regular pandas DataFrame
- where the spatial part is stored
- how to find the geometry column
- why CRS matters before analysis
- what to inspect before using the data
This page explains the basics of a GeoDataFrame: its structure, key parts, and the checks to make before doing spatial work β using real layers for one small county, Rutland: food businesses from the Food Standards Agency, roads from OpenStreetMap, ward boundaries from the ONS, and land parcels from HM Land Registry.
Quick answer
A GeoDataFrame is a pandas DataFrame with one active geometry column. It stores normal tabular attributes such as names, IDs, or population, plus spatial features such as Point, LineString, or Polygon geometries.
A GeoDataFrame also often carries a coordinate reference system (CRS). The active geometry column is what makes the table spatial, and the CRS is what makes the coordinates meaningful for mapping and analysis. Together, they allow GeoPandas to run GIS operations like plotting, reprojection, buffering, clipping, and spatial joins.
Anatomy of a GeoDataFrame
From a table to a GeoDataFrame: geometry, active column, CRS
Start with a regular table
A GeoDataFrame still works like a table. It has rows and columns, just like pandas.
In GIS terms, each row usually represents one feature, and non-spatial columns store attributes such as:
nameidpopulationland_use
df = pd.DataFrame({
"business": register["BusinessName"].head(3).tolist(),
"rating": register["RatingValue"].head(3).tolist(),
"lon": register["lon"].head(3).tolist(),
"lat": register["lat"].head(3).tolist(),
})
print(df)
print(type(df))
business rating lon lat
0 10 Dining Street 5 -0.731641 52.669524
1 5 Elements Cafe and Lounge 3 -0.722838 52.588372
2 A Simpler Life 5 -0.723331 52.588002
<class 'pandas.DataFrame'>
This is only a regular DataFrame β three real Rutland food businesses with their coordinates as plain numbers. It has no spatial information yet.
Add a geometry column
A GeoDataFrame becomes spatial when one column contains geometry objects.
Common geometry types are:
PointLineStringPolygon
gdf = gpd.GeoDataFrame(
df.drop(columns=["lon", "lat"]),
geometry=gpd.points_from_xy(df["lon"], df["lat"]),
crs="EPSG:4326",
)
print(gdf)
print(type(gdf))
business rating geometry
0 10 Dining Street 5 POINT (-0.73164 52.66952)
1 5 Elements Cafe and Lounge 3 POINT (-0.72284 52.58837)
2 A Simpler Life 5 POINT (-0.72333 52.588)
<class 'geopandas.geodataframe.GeoDataFrame'>
Now the table is a GeoDataFrame because it has an active geometry column. Here, it also has a CRS set to EPSG:4326.
Understand the active geometry column
A GeoDataFrame can contain multiple columns, but one geometry column is treated as the active geometry column. GeoPandas uses that column for most spatial operations.
This active geometry is what methods use for:
- plotting
- buffering
- reprojection with
to_crs() - spatial joins
- overlays
print(gdf.geometry.name)
print(gdf.geometry.head())
geometry
0 POINT (-0.73164 52.66952)
1 POINT (-0.72284 52.58837)
2 POINT (-0.72333 52.588)
Name: geometry, dtype: geometry
If the active geometry column is wrong, your GIS operations will use the wrong spatial data.
Check the coordinate reference system (CRS)
The CRS tells GeoPandas how to interpret coordinates.
For example:
EPSG:4326usually means longitude and latitude in degrees- a projected CRS such as British National Grid (
EPSG:27700) or a local UTM CRS uses linear units such as metres
print(gdf.crs)
print(gdf.crs.name, "| geographic:", gdf.crs.is_geographic, "| units:", gdf.crs.axis_info[0].unit_name)
EPSG:4326
WGS 84 | geographic: True | units: degree
If the CRS is missing or wrong, you can get:
- features drawn in the wrong place
- incorrect distance or area results
- failed overlay or join operations
Inspect the GeoDataFrame before using it
Before analysis, check the basic structure.
located = gpd.GeoDataFrame(register[["BusinessName", "RatingValue"]],
geometry=gpd.points_from_xy(register["lon"], register["lat"]), crs=4326)
print(located.head(3))
print(located.columns.tolist())
print(located.geometry.name)
print(located.geom_type.value_counts().to_dict())
print(located.crs)
print("missing geometries:", located.geometry.isna().sum())
print("all valid:", located.is_valid.all(), "| invalid:", (~located.is_valid).sum())
BusinessName RatingValue geometry
0 10 Dining Street 5 POINT (-0.73164 52.66952)
1 5 Elements Cafe and Lounge 3 POINT (-0.72284 52.58837)
2 A Simpler Life 5 POINT (-0.72333 52.588)
['BusinessName', 'RatingValue', 'geometry']
geometry
{'Point': 462}
EPSG:4326
missing geometries: 0
all valid: False | invalid: 124
The full register is where the checks earn their place. 124 of its 462 businesses were published without coordinates, points_from_xy turned them into POINT (NaN NaN), and the missing-geometry count says 0 β only the validity check finds them.
These checks help you confirm:
- column names
- active geometry column
- geometry types
- CRS
- missing geometry values
- invalid geometry
Rutland hygiene ratings, roads and wards as GeoDataFrames
The data used on this page
The Food Standards Agency's food hygiene rating data for Rutland (OGL); roads and points of interest from Geofabrik's OpenStreetMap extract of 1 January 2026 (Β© OpenStreetMap contributors, ODbL); Rutland's wards from the ONS December 2024 boundaries (OGL); and HM Land Registry's INSPIRE Index Polygons (Β© Crown copyright; OGL).
import json
from pathlib import Path
import geopandas as gpd
import pandas as pd
import requests
Path("data").mkdir(exist_ok=True)
downloads = {
# 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
"data/food.json": "https://data.spatialworkflow.io/fhrs-879-2026-10-09.json",
"data/rutland.shp.zip": "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip",
"data/Rutland.zip": "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip",
}
for target, url in downloads.items():
if not Path(target).exists():
Path(target).write_bytes(requests.get(url, timeout=300).content)
if not Path("data/wards.geojson").exists():
Path("data/wards.geojson").write_bytes(requests.get(
"https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
params={"where": "LAD24CD='E06000017'", "outFields": "WD24NM", "outSR": 27700, "f": "geojson"},
timeout=120).content)
records = json.loads(Path("data/food.json").read_text())["FHRSEstablishment"]["EstablishmentCollection"]
register = pd.json_normalize(records)
register["lon"] = pd.to_numeric(register["Geocode.Longitude"], errors="coerce")
register["lat"] = pd.to_numeric(register["Geocode.Latitude"], errors="coerce")
OSM = "/vsizip/data/rutland.shp.zip/"
wards = gpd.read_file("data/wards.geojson")
parcels = gpd.read_file("/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml",
columns=["INSPIREID"], DOWNLOAD_SCHEMA="NO")
print(f"{len(register)} businesses, {len(wards)} wards, {len(parcels):,} parcels")
462 businesses, 15 wards, 21,253 parcels
Example 1: Load a GeoDataFrame from a shapefile
This shows what a real GeoDataFrame looks like after loading GIS data.
roads = gpd.read_file(OSM + "gis_osm_roads_free_1.shp")
print(roads.head(3))
print(roads.columns.tolist())
print(roads.geometry.head(3))
print(roads.crs)
osm_id code ... tunnel geometry
0 2484492 5112 ... F LINESTRING (-0.72461 52.59642, -0.72452 52.596...
1 2484493 5112 ... F LINESTRING (-0.64671 52.59353, -0.6459 52.5934...
2 2956385 5113 ... F LINESTRING (-0.63426 52.59321, -0.63372 52.593...
[3 rows x 11 columns]
['osm_id', 'code', 'fclass', 'name', 'ref', 'oneway', 'maxspeed', 'layer', 'bridge', 'tunnel', 'geometry']
0 LINESTRING (-0.72461 52.59642, -0.72452 52.596...
1 LINESTRING (-0.64671 52.59353, -0.6459 52.5934...
2 LINESTRING (-0.63426 52.59321, -0.63372 52.593...
Name: geometry, dtype: geometry
EPSG:4326
The result is typical: attribute columns such as the road's name, class (fclass) and speed limit, one geometry column, and a CRS read from the shapefile's .prj.
Example 2: Compare a pandas DataFrame and a GeoDataFrame
This makes the DataFrame versus GeoDataFrame difference clear.
plain = register[["BusinessName", "lon", "lat"]].dropna().head(2)
print(type(plain).__name__, plain.columns.tolist())
spatial = gpd.GeoDataFrame(plain, geometry=gpd.points_from_xy(plain["lon"], plain["lat"]), crs="EPSG:4326")
print(type(spatial).__name__, spatial.columns.tolist())
print(spatial.to_crs(27700).geometry.round(1).tolist())
DataFrame ['BusinessName', 'lon', 'lat']
GeoDataFrame ['BusinessName', 'lon', 'lat', 'geometry']
[<POINT (485866.348 308733.898)>, <POINT (486621.513 299717.766)>]
A DataFrame stores plain values. A GeoDataFrame adds a geometry column and CRS metadata β which is what lets to_crs() turn the same two businesses into British National Grid metres.
Example 3: Inspect geometry types
Geometry type affects what operations make sense later.
layers = {
"parcels": parcels,
"roads": roads,
"pois (points)": gpd.read_file(OSM + "gis_osm_pois_free_1.shp"),
"pois (areas)": gpd.read_file(OSM + "gis_osm_pois_a_free_1.shp"),
}
for name, layer in layers.items():
print(f"{name:14} {layer.geom_type.value_counts().to_dict()}")
parcels {'Polygon': 21253}
roads {'LineString': 7292}
pois (points) {'Point': 531}
pois (areas) {'Polygon': 536, 'MultiPolygon': 2}
Three of the four layers hold one geometry type; the area points of interest mix 536 Polygons with 2 MultiPolygons, which most polygon code handles but a strict type check would reject. And the same kind of thing β a school, a pub, a car park β appears in two layers depending on whether a mapper drew it as a point or an outline. That matters because a polygon workflow and a point workflow are different: a point has no area, and a join that expects one type quietly drops the other.
Example 4: Identify the active geometry column
A table can have more than one geometry-like column, but only one is active.
wards2 = wards.assign(centre=wards.representative_point())
print("Active geometry:", wards2.geometry.name, "|", wards2.geom_type.iloc[0])
print("total area, active geometry:", round(wards2.area.sum() / 1e6, 1), "kmΒ²")
wards2 = wards2.set_geometry("centre")
print("New active geometry:", wards2.geometry.name, "|", wards2.geom_type.iloc[0])
print("total area, active geometry:", wards2.area.sum(), "mΒ²")
Active geometry: geometry | Polygon
total area, active geometry: 393.7 kmΒ²
New active geometry: centre | Point
total area, active geometry: 0.0 mΒ²
This is useful when you keep original and derived geometry columns in the same dataset β a ward outline and a labelling point, say. The same .area call gives Rutland's 393.7 kmΒ² or zero, depending only on which column is active.
Example 5: Check CRS before analysis
Always inspect CRS before distance, buffer, or overlay operations.
def describe_crs(gdf, name):
if gdf.crs is None:
return f"{name}: CRS is missing. Fix this before spatial analysis."
if gdf.crs.is_geographic:
return f"{name}: {gdf.crs.to_string()} is geographic (degrees). Project it before measuring distance or area."
return f"{name}: {gdf.crs.to_string()} is projected, in {gdf.crs.axis_info[0].unit_name}s."
# a copy of the roads without its .prj file, as shapefiles often arrive
roads.head(50).to_file("data/roads_copy.shp")
Path("data/roads_copy.prj").unlink()
for layer, name in ((roads, "roads"), (wards, "wards"), (gpd.read_file("data/roads_copy.shp"), "roads without .prj")):
print(describe_crs(layer, name))
roads: EPSG:4326 is geographic (degrees). Project it before measuring distance or area.
wards: EPSG:27700 is projected, in metres.
roads without .prj: CRS is missing. Fix this before spatial analysis.
What a GeoDataFrame adds to a DataFrame
How a GeoDataFrame is different from a DataFrame
A pandas DataFrame only stores tabular values. It does not understand geometry, mapping, or spatial relationships.
A GeoDataFrame adds:
- an active geometry column
- optional CRS metadata
- GIS methods such as
plot(),to_crs(),buffer(),clip(), and spatial joins
That is the core difference.
What each row represents
In most GIS workflows, each row is one spatial feature. For example:
- one city point
- one road line
- one parcel polygon
The other columns describe that feature. This is the basic structure used in shapefiles, GeoJSON, and many other vector data formats.
Why the geometry column matters
The geometry column in GeoPandas stores the actual shapes. GeoPandas uses that column for spatial methods and analysis.
Many common errors come from:
- missing geometry values
- invalid polygons
- mixed geometry types
- the wrong active geometry column
If a script fails during plotting or spatial analysis, the geometry column is one of the first things to check.
Why CRS matters for real GIS work
The coordinate reference system in a GeoDataFrame controls how coordinates are interpreted.
In practice, a bad or missing CRS can cause:
- wrong map placement
- incorrect area and distance calculations
- features that do not overlap when they should
- failed or misleading spatial joins
For that reason, checking .crs should be a standard first step.
Non-spatial columns, missing geometry and missing CRSs
A GeoDataFrame can have non-spatial columns
Most columns are often plain text, integers, floats, or dates. Only one column needs to be the active geometry column.
Geometry values can be missing or invalid
Real datasets often contain null geometry or invalid polygons. If the geometry is missing or invalid, the object may still be a GeoDataFrame, but spatial operations can fail or produce unreliable results.
Check for both before analysis:
print(located.geometry.isna().sum())
print(located.is_valid.value_counts().to_dict())
0
{True: 338, False: 124}
Multiple geometry-like columns can exist
You can store several geometry-related columns, but GeoPandas uses only one active geometry column at a time.
CRS may be missing
Some files load without CRS metadata, and manually created geometry may also have no CRS unless you set it. Do not assume CRS is present.
Internal links
For a broader introduction, read GeoPandas Basics: Working with Spatial Data in Python.
For related tasks, see How to Read a Shapefile with GeoPandas and How to Create a GeoDataFrame from Latitude and Longitude in Python.
If your data has no coordinate system defined, use How to Fix Missing CRS in a GeoDataFrame.
FAQ
Is a GeoDataFrame the same as a pandas DataFrame?
No. A GeoDataFrame is built on pandas, but it adds an active geometry column and can also store CRS metadata for spatial work.
What is the geometry column in GeoPandas?
It is the column that stores spatial objects such as points, lines, or polygons and is used for GIS operations.
Can a GeoDataFrame have more than one geometry column?
Yes. A GeoDataFrame can contain multiple geometry-like columns, but only one is active for most GeoPandas operations at a time.
How do I turn a GeoDataFrame back into a plain pandas DataFrame?
Drop the geometry column and cast it, for example pd.DataFrame(gdf.drop(columns="geometry")). The result loses the active geometry and CRS, so only do this when you no longer need spatial operations.
What dtype does the geometry column use?
It uses GeoPandas' geometry extension dtype, which stores Shapely geometry objects backed by a vectorized array. You can confirm it with gdf.dtypes, where the active geometry column shows geometry rather than object.
Can the active geometry column be named something other than geometry?
Yes. The active column can have any name, and you set it with set_geometry("col_name"); check the current name with gdf.geometry.name. Files like shapefiles or GeoPackages may load it under a different name such as geom.
What happens to the CRS when I concatenate or merge two GeoDataFrames?
Attribute merges with pd.concat do not reproject, so the layers must already share the same CRS or the combined coordinates will be inconsistent. Align them first with to_crs(), and be aware that pd.concat only preserves the CRS reliably when both inputs carry the same one.