How to Get Polygon Centroids in GeoPandas

On this page

Problem statement

.centroid gives every polygon a point β€” the centre of its area, which for a crescent or a C-shaped polygon falls outside the shape itself. Centroids are needed to:

  • label polygons with point locations
  • create point layers from area features
  • prepare polygon-derived points for joins, exports, or analysis
  • generate point outputs from polygon layers for automation workflows

The practical questions are usually:

  • which GeoPandas method to use
  • what type of result it returns
  • whether the centroid is reliable in the current CRS
  • why some centroids appear outside polygon boundaries

The examples on this page use two real layers: the UK's 361 local authority districts from the Office for National Statistics, which arrive in longitude and latitude, and HM Land Registry's 21,253 freehold parcels for Rutland, which arrive in British National Grid. The setup under Code examples at the end of the page downloads both.

Quick answer

For a concave polygon the centroid can fall outside the shape, while representative_point is always inside. centroid (outside) representative_point (inside) spatialworkflow.io
For a concave shape the centroid can fall outside the polygon, while representative_point stays inside.

To get polygon centroids in GeoPandas, reproject to a projected CRS for your area and use the .centroid attribute on the geometry column:

import geopandas as gpd

districts = gpd.read_file("data/districts.gpkg")

projected = districts.to_crs("EPSG:27700")
projected["centroid"] = projected.geometry.centroid
print(projected[["LAD24NM", "centroid"]].head(3))
                LAD24NM                       centroid
0            Hartlepool  POINT (447872.668 530734.523)
1         Middlesbrough  POINT (450413.779 516583.543)
2  Redcar and Cleveland  POINT (463440.663 517825.132)

This returns a GeoSeries of Point geometries, one for each polygon or multipolygon. For Great Britain the projected CRS is the British National Grid (EPSG:27700); elsewhere, use your national grid or UTM zone.

From district polygons to a centroid layer

A centroid falling outside a crescent-shaped polygon, and a representative point inside it. a centroid is not always insidecentroidnot on the polygonoutside the shaperepresentative_point()always withinguaranteed insideCrescents, rings and multipolygons all put their centroid outside the shape spatialworkflow.io
For labelling a map, representative_point() is nearly always the right choice.

Load polygon data into a GeoDataFrame

Read the layers

Start by loading polygon data from a real GIS file. GeoPandas reads GeoPackage, shapefile, GeoJSON and GML the same way.

import geopandas as gpd

districts = gpd.read_file("data/districts.gpkg")
parcels = gpd.read_file("data/parcels.gpkg")
print(districts.head(3))
     LAD24CD  ...                                           geometry
0  E06000001  ...  MULTIPOLYGON (((-1.23001 54.62512, -1.2299 54....
1  E06000002  ...  MULTIPOLYGON (((-1.27712 54.54791, -1.2772 54....
2  E06000003  ...  MULTIPOLYGON (((-1.20098 54.57763, -1.20037 54...

[3 rows x 3 columns]

Check geometry type and CRS

Before calculating centroids, confirm that the geometry column contains polygon data and see what CRS it is in.

for name, layer in [("districts", districts), ("parcels", parcels)]:
    print(name, layer.crs, layer.geom_type.value_counts().to_dict())
districts EPSG:4326 {'MultiPolygon': 361}
parcels EPSG:27700 {'Polygon': 21253}

The districts are in EPSG:4326, so their coordinates are degrees. GeoPandas warns if you ask for a centroid in that CRS:

import warnings

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    districts.geometry.centroid
print(caught[0].category.__name__, str(caught[0].message).strip())
UserWarning Geometry is in a geographic CRS. Results from 'centroid' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation.

Calculate polygon centroids

Reproject before calculating centroids

A centroid computed on degrees treats a degree of longitude as the same size as a degree of latitude, which at British latitudes it is not. How far that moves the point depends on the size of the polygon. Here are the centroids of Great Britain's 350 districts computed in three CRSs and measured against the British National Grid result:

gb = districts[~districts["LAD24CD"].str.startswith("N")]   # Northern Ireland uses the Irish Grid
reference = gb.to_crs("EPSG:27700").geometry.centroid

for crs in ["EPSG:4326", "EPSG:3857"]:
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        other = gb.to_crs(crs).geometry.centroid.to_crs("EPSG:27700")
    shift = other.distance(reference)
    print(f"{crs}: median {shift.median():.1f} m, largest {shift.max():.0f} m ({gb.loc[shift.idxmax(), 'LAD24NM']})")

shift = parcels.to_crs("EPSG:3857").geometry.centroid.to_crs("EPSG:27700").distance(parcels.geometry.centroid)
print(f"Rutland parcels, EPSG:3857: largest shift {shift.max():.2f} m")
EPSG:4326: median 3.9 m, largest 916 m (Highland)
EPSG:3857: median 10.3 m, largest 2290 m (Highland)
Rutland parcels, EPSG:3857: largest shift 0.32 m

Web Mercator (EPSG:3857) is often suggested as a quick projected CRS, but for centroids it does worse than not projecting at all: its scale grows towards the pole, so it pulls every district's centroid north. On parcel-sized polygons the CRS barely matters; on a district the size of Highland it moves the point by kilometres. Use the national grid or a local UTM zone.

How far district centroids move when computed in EPSG:4326 or EPSG:3857 instead of British National Grid. centroid shift against British National Grid, 350 GB districtsEPSG:4326, median3.9 mEPSG:4326, Highland916 mEPSG:3857, median10.3 mEPSG:3857, Highland2,290 mEPSG:3857, parcels0.32 m at most01000 m2000 mWeb Mercator moves centroids further than not projecting spatialworkflow.io
Measured above: the error grows with the size of the polygon, and Web Mercator makes it larger than leaving the data in degrees.

Inspect one centroid result

projected = districts.to_crs("EPSG:27700")
projected["centroid"] = projected.geometry.centroid

first_centroid = projected["centroid"].iloc[0]
print(projected["LAD24NM"].iloc[0], first_centroid, first_centroid.geom_type)
Hartlepool POINT (447872.6675601607 530734.5229375327) Point

Keep the original polygon geometry

In most workflows, keep centroids in a separate column, as above, so you do not overwrite the polygon geometry too early. The active geometry is still the polygon:

print(projected.geometry.name, projected.geom_type.unique().tolist())
geometry ['MultiPolygon']

Create a centroid point layer from polygons

Make centroid geometry the active geometry

If you want to work with centroid points as the active geometry, use set_geometry().

centroid_gdf = projected.set_geometry("centroid")
print(centroid_gdf.geometry.name, centroid_gdf.geom_type.unique().tolist())
centroid ['Point']

Now centroid_gdf behaves like a point layer. It still carries the polygon column as an ordinary attribute, and most file formats hold only one geometry column, so drop it before you export.

Replace geometry in a copied GeoDataFrame

Another common pattern is to create a new GeoDataFrame and replace the geometry column.

centroid_gdf = projected.drop(columns="centroid").copy()
centroid_gdf["geometry"] = projected.geometry.centroid
print(centroid_gdf.geom_type.unique().tolist(), districts.geom_type.unique().tolist())
['Point'] ['MultiPolygon']

This leaves the original districts untouched while giving you a point-based output.

Keep selected attributes only

If you only want a few fields plus centroid geometry:

centroid_gdf = gpd.GeoDataFrame(
    projected[["LAD24CD", "LAD24NM"]],
    geometry=projected.geometry.centroid,
    crs=projected.crs,
)
print(centroid_gdf.head(3))
     LAD24CD               LAD24NM                       geometry
0  E06000001            Hartlepool  POINT (447872.668 530734.523)
1  E06000002         Middlesbrough  POINT (450413.779 516583.543)
2  E06000003  Redcar and Cleveland  POINT (463440.663 517825.132)

Convert centroid output back to the original CRS

If your source data was in EPSG:4326 or another output CRS is required, convert the centroid layer back after calculating the centroids in projected coordinates.

centroid_gdf = centroid_gdf.to_crs(districts.crs)
print(centroid_gdf.crs, centroid_gdf.geometry.iloc[0])
EPSG:4326 POINT (-1.2592455352589802 54.669418859759176)

Export centroid points

Once the centroid layer is ready, export it like any other GeoDataFrame.

from pathlib import Path

Path("output").mkdir(exist_ok=True)
centroid_gdf.to_file("output/district_centroids.gpkg")
centroid_gdf.to_file("output/district_centroids.geojson", driver="GeoJSON")
print(gpd.read_file("output/district_centroids.gpkg").geom_type.value_counts().to_dict())
{'Point': 361}

Check whether centroids fall outside polygons

Why this happens

A centroid is the geometric center of a polygon, but it is not guaranteed to fall inside the polygon. This is common with:

  • concave polygons
  • polygons with holes
  • irregular multipart features

Count how often it happens on your own data before you rely on centroids as labels or join keys:

districts_bng = districts.to_crs("EPSG:27700")
centres = districts_bng.set_geometry(districts_bng.geometry.centroid)[["LAD24NM", "geometry"]]
landed = gpd.sjoin(centres, districts_bng[["LAD24NM", "geometry"]], how="left", predicate="within")
outside = landed[landed["LAD24NM_left"] != landed["LAD24NM_right"]]
print(outside[["LAD24NM_left", "LAD24NM_right"]].fillna("(sea or outside the UK)").to_string(index=False))

parcel_outside = ~parcels.contains(parcels.geometry.centroid)
print(f"{parcel_outside.sum()} of {len(parcels)} parcels ({parcel_outside.mean():.1%}) have their centroid outside")
         LAD24NM_left           LAD24NM_right
               Torbay (sea or outside the UK)
      Isles of Scilly (sea or outside the UK)
North East Derbyshire            Chesterfield
               Maldon (sea or outside the UK)
               Sefton         West Lancashire
       North Ayrshire (sea or outside the UK)
       Orkney Islands (sea or outside the UK)
436 of 21253 parcels (2.1%) have their centroid outside

North East Derbyshire wraps round Chesterfield, so its centroid lands in the town it surrounds; Sefton's coastal strip puts its centroid in West Lancashire; the island groups and the districts shaped round a bay or an estuary put theirs in the sea. A spatial join that uses these points assigns them to the wrong district, or to none.

The seven UK districts whose centroid falls outside the district, and where each centroid lands. 7 of 361 districts: where the centroid landsDistrictCentroid lands inNorth East DerbyshireChesterfield, which it surroundsSeftonWest LancashireOrkney Islands (177 parts)the seaIsles of Scillythe seaNorth Ayrshirethe seaTorbaythe seaMaldonthe searepresentative_point() put all 361 inside spatialworkflow.io
Measured above with a spatial join of each centroid against the districts. A join keyed on these points puts two districts in their neighbours and five in no district at all.

Use representative points when you need an interior point

If you need a point guaranteed to lie inside the polygon, use .representative_point() instead.

rep = districts_bng.geometry.representative_point()
print("district points outside:", (~districts_bng.contains(rep)).sum())
print("parcel points outside:", (~parcels.contains(parcels.geometry.representative_point())).sum())
district points outside: 0
parcel points outside: 0

Use centroids when you need the geometric center. Use representative points when you need a point inside the polygon for labeling, display, or a join.

Where GeoPandas centroids come from

The centroid property in GeoPandas comes from the underlying Shapely (GEOS) operation. When you run:

projected.geometry.centroid
Choosing between centroid and representative_point. which point do you actually want?guaranteesuse it for.centroid!centre of massstatistics, distances, weighting.representative_point()βœ“a point insidelabels, markers, joinsBoth need a projected CRS β€” a centroid computed in degrees is skewed spatialworkflow.io
The choice depends entirely on whether the point must lie inside the polygon.

GeoPandas returns a GeoSeries of Point geometries, one for each polygon or multipolygon.

You can use that result in three common ways:

  1. store it in a new column
  2. make it the active geometry with set_geometry()
  3. replace the geometry column in a copied GeoDataFrame to create a point layer

The main practical issue is CRS choice. The centroid is computed on whatever plane the coordinates describe; in EPSG:4326 that plane is longitude against latitude, which is not an equal-area picture of the ground. The error grows with the size of the polygon β€” under a millimetre for a median Rutland parcel, hundreds of metres for the largest Scottish district.

The difference between centroid and representative point is also important:

  • Centroid: geometric center, may fall outside the polygon
  • Representative point: inside the polygon, but not the true geometric center

Typical GIS uses for polygon centroids include:

  • creating parcel label points
  • generating center points for export
  • preparing polygon-derived points for spatial joins
  • simplifying polygon layers for downstream automation

Geographic CRSs, multipart polygons and empty shapes

  • Geographic CRS: Avoid calculating centroids directly in EPSG:4326 unless approximate output is acceptable; the size of the error scales with the polygon.
  • Projected CRS choice: Use a national grid or a local UTM zone. EPSG:3857 moved district centroids further than EPSG:4326 did.
  • Multipart polygons: A multipolygon gets one centroid for the full geometry, weighted by the area of its parts, not one centroid per part. The Orkney Islands district is one feature of 177 parts, and its centroid is in the sea. Use explode(index_parts=False) first if you want a point per island.
  • Invalid geometries: Invalid polygons can produce unreliable results. Check validity first, and repair with make_valid() rather than buffer(0) (see the check below).
  • Empty or null geometries: A null geometry gives a null centroid and an empty one an empty point; filter them out first with parcels[parcels.geometry.notna() & ~parcels.geometry.is_empty].
  • Performance: Centroid calculation is vectorized in GEOS, so it runs over the whole column at once; there is no need to loop over rows.

The validity check, why buffer(0) is the wrong repair for a self-intersecting shape, Orkney's parts, and what null and empty geometries give back:

from shapely.geometry import Polygon

print("invalid parcels:", (~parcels.is_valid).sum())
bowtie = gpd.GeoSeries([Polygon([(0, 0), (2, 2), (2, 0), (0, 2)])])
print("buffer(0) area:", bowtie.buffer(0).area.iloc[0], "| make_valid area:", bowtie.make_valid().area.iloc[0])

print("Orkney parts:", len(districts[districts["LAD24NM"] == "Orkney Islands"].explode(index_parts=False)))
print(gpd.GeoSeries([None, Polygon()]).centroid.tolist())
invalid parcels: 0
buffer(0) area: 1.0 | make_valid area: 2.0
Orkney parts: 177
[None, <POINT EMPTY>]

The bowtie covers two triangles of area 1 each. buffer(0) keeps one of them; make_valid() keeps both as a MultiPolygon.

UK districts and Rutland parcels

The data used on this page

The ONS Local Authority Districts (December 2024) generalised boundaries for the UK, from the ONS Open Geography Portal's ArcGIS service, and HM Land Registry's INSPIRE Index Polygons for Rutland (Open Government Licence, Β© Crown copyright and database rights). The Land Registry download service sets a cookie before it serves the file, so it is fetched with a requests.Session.

import io
import zipfile
from pathlib import Path

import geopandas as gpd
import requests

Path("data").mkdir(exist_ok=True)
if not Path("data/districts.gpkg").exists():
    gpd.read_file(
        "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
        "Local_Authority_Districts_December_2024_Boundaries_UK_BGC/FeatureServer/0/query"
        "?where=1%3D1&outFields=LAD24CD,LAD24NM&f=geojson"
    ).to_file("data/districts.gpkg")
if not Path("data/parcels.gpkg").exists():
    reply = requests.Session().get(
        "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300
    )
    reply.raise_for_status()
    zipfile.ZipFile(io.BytesIO(reply.content)).extract("Land_Registry_Cadastral_Parcels.gml", "data")
    gpd.read_file("data/Land_Registry_Cadastral_Parcels.gml")[["INSPIREID", "geometry"]].to_file("data/parcels.gpkg")

print(len(gpd.read_file("data/districts.gpkg")), "districts,", len(gpd.read_file("data/parcels.gpkg")), "parcels")
361 districts, 21253 parcels

For a broader concept, see How to Work with Geometry Columns in GeoPandas.

For related tasks, see How to Read a Shapefile in Python with GeoPandas and How to Reproject Spatial Data in Python (GeoPandas).

If centroids land in the wrong zone after a spatial join, see How to Perform a Spatial Join in Python (GeoPandas).

FAQ

How do I get the centroid of a polygon in GeoPandas?

Use the .centroid attribute on the geometry column:

projected["centroid"] = projected.geometry.centroid

This creates one point for each polygon or multipolygon.

Does GeoPandas return one centroid per polygon?

Yes. GeoPandas returns one Point geometry for each polygon or multipolygon, unless the geometry is null or empty.

Why is my centroid outside the polygon?

That can be normal. Concave polygons, polygons with holes, and multipart shapes can have centroids outside their boundaries. If you need an interior point, use:

projected["rep_point"] = projected.geometry.representative_point()

Should I reproject before calculating centroids?

Usually yes. If your data is in a geographic CRS such as EPSG:4326, reproject to a suitable projected CRS before calculating centroids.

What is the difference between centroid and representative point?

A centroid is the geometric center of the polygon. A representative point is guaranteed to lie inside the polygon. They are useful for different GIS tasks.

How do I extract the x and y coordinates of each centroid into columns?

After computing centroids on a projected layer, use the .x and .y accessors, for example gdf["cx"] = gdf.geometry.centroid.x and gdf["cy"] = gdf.geometry.centroid.y. These return plain numeric Series in the layer's CRS units.

How do I get one centroid per part of a MultiPolygon instead of one for the whole feature?

Explode the multipart geometries first with gdf.explode(index_parts=False), which gives one row per polygon part, then call .centroid on the result. Each part then gets its own center point.

Can I label a map at polygon centroids using these points?

Yes. Compute the points in a projected CRS, convert them to the map's CRS, then in Matplotlib iterate and call ax.annotate(text, (point.x, point.y)) for each row, or use representative_point() so the label always sits inside the polygon. Representative points avoid labels landing outside concave or ring-shaped areas.

Does .centroid weight by area for a MultiPolygon with parts of different sizes?

Yes. The centroid of a MultiPolygon is the area-weighted center of all its parts, so larger parts pull the point toward themselves. For the Orkney Islands district that puts the point in the sea between the islands; use representative_point() to get a location that lies on the geometry.