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
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
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.
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.
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
GeoPandas returns a GeoSeries of Point geometries, one for each polygon or multipolygon.
You can use that result in three common ways:
- store it in a new column
- make it the active geometry with
set_geometry() - 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 thanbuffer(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
Internal links
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.