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

Anatomy of a GeoDataFrameGeoDataFrameAttribute columns (name, id,population)Active geometry column (Point /Line / Polygon)Optional inactive geometry columnCRS metadata on the geometryspatialworkflow.io
The parts of a GeoDataFrame: attribute columns, the active geometry, and CRS metadata.

From a table to a GeoDataFrame: geometry, active column, CRS

A GeoDataFrame can hold several geometry columns but only one is active. several geometry columns, one activeWD24NMgeometrycentre (active, as lon/lat)Oakham North EastPOLYGON ((…))POINT (-0.726 52.676)Oakham North WestPOLYGON ((…))POINT (-0.746 52.670)set_geometry() switches which column .area, .plot() and sjoin() useEvery geometry column has its own CRS; only the active one answers .area and .plot() spatialworkflow.io
Keeping polygons and centroids side by side is a normal and useful pattern.

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:

  • name
  • id
  • population
  • land_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:

  • Point
  • LineString
  • Polygon
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:4326 usually 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.

What inspecting each Rutland layer on the page found, and the check that catches it. the layers on this page, inspectedwhat inspection foundwhat to doFSA register: 124 POINT (NaN NaN)check is_valid, not only isnaOSM roads: EPSG:4326, degreesto_crs() before measuringOSM area POIs: 536 Polygon, 2Multiaccept both polygon typeswards, points active: area 0check geometry.nameroads copy without .prj: CRS Noneset_crs() from the sourceAll five are GeoDataFrames; only inspecting them shows the difference spatialworkflow.io
What the inspections on this page found, layer by layer.

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

What a GeoDataFrame adds to a pandas DataFrame. what the "Geo" addsDataFrameGeoDataFramerows and columnsβœ“yesβœ“yesgroupby, mergeβœ“yesβœ“yes.crsβœ•noβœ“yes.plot()!a chartβœ“a mapsjoin, clip, overlayβœ•noβœ“yesto_file()!to_csv, to_parquetβœ“any GDAL formatEverything you know about pandas still applies β€” the spatial parts are additions spatialworkflow.io
A GeoDataFrame is a DataFrame that has not lost any abilities, only gained some.

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.

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.