How to Create Buffers in GeoPandas for Spatial Analysis
On this page
Problem statement
A buffer is the area within a distance of a feature: the 500 m around a school, the 100 m corridor along a road. GeoSeries.buffer(500) makes one per feature, in the units of the layer's CRS. The question it leaves open is what to do with them next, because buffers of neighbouring features overlap: add up their areas and the same ground is counted several times over.
Everything on this page is in Manchester — its schools, main roads and tram stops from OpenStreetMap, and its Land Registry parcels — and each of those effects is measured on it.
Quick answer
Use GeoSeries.buffer() on the geometry column, and reproject first if needed.
import geopandas as gpd
gdf = gpd.read_file("data/facilities.shp")
# Reproject to a CRS with linear units before buffering
gdf = gdf.to_crs("EPSG:27700") # British National Grid, metres
gdf["geometry"] = gdf.geometry.buffer(500) # 500 meters
print(len(gdf), "buffers;", round(gdf.area.iloc[0]), "m² each")
206 buffers; 784137 m² each
A 500 m circle is π × 500² = 785,398 m². The buffer is a polygon with 16 segments per quarter circle, so it comes out a little smaller.
A projected CRS, then buffer and store
Load spatial data into a GeoDataFrame
Read your shapefile or GeoJSON, then check the geometry type and CRS.
import geopandas as gpd
facilities = gpd.read_file("data/facilities.shp")
print(facilities.head())
print(facilities.geom_type.unique())
print(facilities.crs)
name geometry
0 Eastmoor School POINT (-2.20442 53.51198)
1 Abbey College Manchester POINT (-2.24405 53.48138)
2 Cedar Mount Academy POINT (-2.17511 53.45073)
3 Melland High School POINT (-2.1751 53.45022)
4 Eden Boys’ Leadership Academy POINT (-2.23118 53.49719)
<ArrowStringArray>
['Point']
Length: 1, dtype: str
EPSG:4326
Check whether the CRS is suitable for buffer distance
Buffer distance uses the units of the current CRS. If the CRS is geographic, such as EPSG:4326, the units are degrees, not meters.
print(facilities.crs, "— units:", facilities.crs.axis_info[0].unit_name)
EPSG:4326 — units: degree
Degrees, so the distance would be degrees too:
# Avoid this if the CRS is EPSG:4326
wrong = facilities.geometry.buffer(500)
print(wrong.total_bounds.round())
[-502. -447. 498. 554.]
Here, 500 means 500 degrees, not 500 meters: every school's "buffer" runs from about −500 to +500 in both longitude and latitude, far beyond the edges of the world.
buffer(1 / 111.32) in EPSG:4326 comes out as an ellipse, about 1 km north–south but only 0.6 km east–west. It covers 59.6% of the circle that was meant, shown dashed, which was buffered in EPSG:27700.Reproject to a projected CRS
British National Grid, in metres, for Manchester:
facilities_proj = facilities.to_crs("EPSG:27700")
print(facilities_proj.crs, "— units:", facilities_proj.crs.axis_info[0].unit_name)
EPSG:27700 — units: metre
Elsewhere, use the national grid or a UTM zone for the area (choosing a projected CRS).
Create the buffer geometry
Apply .buffer(distance) to the geometry column.
facilities_proj["geometry"] = facilities_proj.geometry.buffer(500)
print(facilities_proj.geom_type.unique())
<ArrowStringArray>
['Polygon']
Length: 1, dtype: str
Points, lines and polygons all come back as polygons.
Store buffers in a new column or replace geometry
If you want to keep the original geometry, create a separate column for buffers.
facilities_proj = facilities.to_crs("EPSG:27700") # the points again
facilities_proj["buffer_geom"] = facilities_proj.geometry.buffer(500)
If you want to use the buffer column as the active geometry:
buffers = facilities_proj.set_geometry("buffer_geom")
print(facilities_proj.geometry.geom_type.iloc[0], "->", buffers.geometry.geom_type.iloc[0])
Point -> Polygon
Save the buffer output
If you replaced the active geometry with buffered polygons, save that GeoDataFrame directly:
facilities_proj.set_geometry("buffer_geom").drop(columns="geometry").to_file(
"output/facility_buffers.gpkg", driver="GPKG")
For shapefile output, which holds one geometry column only:
facilities_proj.set_geometry("buffer_geom").drop(columns="geometry").to_file("output/facility_buffers.shp")
If you created a separate buffer column and set it as active geometry, you cannot save that frame as it stands:
buffers = facilities_proj.set_geometry("buffer_geom")
try:
buffers.to_file("output/facility_buffers_both.gpkg", driver="GPKG")
except ValueError as exc:
print(str(exc)[:112], "…")
# keep the original points as text, so only one geometry column remains
buffers = buffers.assign(point_wkt=buffers["geometry"].to_wkt()).drop(columns="geometry")
buffers.to_file("output/facility_buffers_both.gpkg", driver="GPKG")
print(gpd.read_file("output/facility_buffers_both.gpkg").columns.tolist())
GeoDataFrame contains multiple geometry columns but GeoDataFrame.to_file supports only a single geometry column. …
['name', 'point_wkt', 'geometry']
to_file writes one geometry column per layer and refuses a frame that carries two. Drop the one you do not need, as in the blocks above, or keep it as well-known text in an ordinary column — which is what the last lines do with the original points.
Buffers around Manchester schools and main roads
The data used on this page
Everything is in Manchester. The facilities are the city's schools from OpenStreetMap (points, with school grounds reduced to a point inside them), the roads are its motorways, trunk and primary roads, and the tram stops are the Metrolink stops — all fetched with OSMnx through an Overpass mirror and saved in EPSG:4326, as they come. The parcels are HM Land Registry's INSPIRE Index Polygons for Manchester, in British National Grid. The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.
import io
import os
import zipfile
import geopandas as gpd
import osmnx as ox
import requests
# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-buffer-analysis-python-geopandas-2026-10-10.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api" # overpass-api.de mirror
ox.settings.overpass_rate_limit = False
PLACE = "Manchester, England"
os.makedirs("data", exist_ok=True)
os.makedirs("output", exist_ok=True)
def osm_points(tags, path):
if not os.path.exists(path):
found = ox.features_from_place(PLACE, tags)
found = found[found["name"].notna()].reset_index()
gpd.GeoDataFrame({"name": found["name"]}, geometry=found.geometry.representative_point(),
crs=4326).to_file(path)
osm_points({"amenity": "school"}, "data/facilities.shp")
osm_points({"railway": "tram_stop"}, "data/tram_stops.shp")
if not os.path.exists("data/roads.geojson"):
roads = ox.features_from_place(PLACE, {"highway": ["motorway", "trunk", "primary"]}).reset_index()
roads = roads[roads.geom_type == "LineString"]
roads[["highway", "name", "geometry"]].to_file("data/roads.geojson", driver="GeoJSON")
if not os.path.exists("data/parcels.shp"):
if not os.path.exists("Manchester_City_Council.zip"):
with requests.Session() as session: # the service sets a cookie, then redirects
open("Manchester_City_Council.zip", "wb").write(session.get(
"https://use-land-property-data.service.gov.uk/datasets/inspire/download/"
"Manchester_City_Council.zip", timeout=600).content)
parcels = gpd.read_file("/vsizip/Manchester_City_Council.zip/Land_Registry_Cadastral_Parcels.gml")
parcels = parcels[["INSPIREID", "geometry"]].rename(columns={"INSPIREID": "inspire_id"})
parcels.set_crs(27700, allow_override=True).to_file("data/parcels.shp")
for path in ("data/facilities.shp", "data/tram_stops.shp", "data/roads.geojson", "data/parcels.shp"):
layer = gpd.read_file(path)
print(f"{path:22} {len(layer):7,} {layer.geom_type.iloc[0]:10} EPSG:{layer.crs.to_epsg()}")
data/facilities.shp 206 Point EPSG:4326
data/tram_stops.shp 95 Point EPSG:4326
data/roads.geojson 1,891 LineString EPSG:4326
data/parcels.shp 121,064 Polygon EPSG:27700
Example 1: Buffer point locations by 500 meters
This reads facility points, reprojects to a metric CRS, and creates 500-meter service area buffers.
import geopandas as gpd
facilities = gpd.read_file("data/facilities.shp")
# Reproject to a CRS with meter units
facilities = facilities.to_crs("EPSG:27700")
# Create 500-meter buffers
facility_buffers = facilities.copy()
facility_buffers["geometry"] = facility_buffers.geometry.buffer(500)
print(facility_buffers.head())
print(facility_buffers.geom_type.unique())
name geometry
0 Eastmoor School POLYGON ((387041.07 401719.057, 387038.663 401...
1 Abbey College Manchester POLYGON ((384401.62 398323.472, 384399.213 398...
2 Cedar Mount Academy POLYGON ((388968.259 394899.525, 388965.851 39...
3 Melland High School POLYGON ((388968.552 394843.063, 388966.144 39...
4 Eden Boys’ Leadership Academy POLYGON ((385261.149 400079.715, 385258.742 40...
<ArrowStringArray>
['Polygon']
Length: 1, dtype: str
Example 2: Buffer roads by 100 meters for impact zone analysis
This creates corridor polygons around roads for proximity or overlay analysis.
import geopandas as gpd
roads = gpd.read_file("data/roads.geojson")
# Reproject to projected CRS
roads = roads.to_crs("EPSG:27700")
# Buffer roads by 100 meters
road_buffers = roads.copy()
road_buffers["geometry"] = road_buffers.geometry.buffer(100)
# Save result
road_buffers.to_file("output/road_impact_zones.gpkg", driver="GPKG")
corridor = road_buffers.union_all()
print(f"{len(roads)} road segments; 100 m corridor {corridor.area / 1e6:.1f} km², "
f"{road_buffers.area.sum() / 1e6:.1f} km² before overlaps were merged")
1891 road segments; 100 m corridor 27.0 km², 100.7 km² before overlaps were merged
Example 3: Keep original geometry and add a separate buffer column
Use this when you need to preserve source geometry for later comparison or joins.
import geopandas as gpd
parcels = gpd.read_file("data/parcels.shp").to_crs("EPSG:27700")
parcels["buffer_geom"] = parcels.geometry.buffer(50)
# Set buffer_geom as active geometry only when needed
parcel_buffers = parcels.set_geometry("buffer_geom")
print(parcel_buffers[["inspire_id", "buffer_geom"]].head())
inspire_id buffer_geom
0 59890879 POLYGON ((389655.427 397511.525, 389654.702 39...
1 60022306 POLYGON ((389718.426 397495.404, 389718.411 39...
2 59943948 POLYGON ((389691.253 397464.094, 389686.278 39...
3 60093956 POLYGON ((389655.533 397463.771, 389650.686 39...
4 60138137 POLYGON ((389727.494 397485.367, 389722.454 39...
Example 4: Export buffered features to a new file
This example shows a complete export workflow.
import geopandas as gpd
points = gpd.read_file("data/tram_stops.shp")
points = points.to_crs("EPSG:27700") # British National Grid, metres
points["buffer_geom"] = points.geometry.buffer(1000)
buffers = points.set_geometry("buffer_geom")[["name", "buffer_geom"]].copy()
buffers = buffers.rename_geometry("geometry")
buffers.to_file("output/tram_stop_buffers.gpkg", driver="GPKG")
print(len(buffers), "tram stops; area within 1 km of any:",
round(buffers.union_all().area / 1e6, 1), "km²")
95 tram stops; area within 1 km of any: 68.2 km²
Why buffer areas do not add up
A buffer is computed feature by feature, which is what makes its areas misleading in bulk. Each of Manchester's 1,891 main-road segments gets its own 100 m corridor, and neighbouring segments' corridors overlap almost completely where they meet, cross or run as dual carriageways. Summed, they come to 100.7 km²; the ground actually within 100 m of a main road is 27.0 km², the area of their union. The same holds for points: the 95 tram stops' 1 km buffers cover 68.2 km² of the city once merged. For "how much land is near X", merge first with union_all() and measure the result.
The shape is an approximation too: a point's buffer is a polygon with 16 segments per quarter circle by default, so a 500 m buffer covers 784,137 m² against the circle's 785,398, a 0.16% shortfall that quad_segs reduces when it matters. Units are the other trap, and the one with its own page: a buffer in degrees, not kilometres.
Large distances, invalid input and negative buffers
Large distances
A buffer is exact in the plane of the projection, so over a large area its true ground distance varies with the projection's scale; for country-scale or global buffers use a projection designed for the region, or buffer geodesically.
Buffering invalid geometries
Invalid geometries can produce unexpected results. If buffering fails or creates odd shapes, check geometry validity first.
invalid = parcels[~parcels.is_valid]
print(len(invalid))
0
You may need to repair the geometry before buffering, depending on the dataset.
Negative buffers
Negative buffer values shrink polygons inward.
parcels["inner_geom"] = parcels.geometry.buffer(-10)
print(f"{parcels['inner_geom'].is_empty.sum():,} of {len(parcels):,} parcels vanish at -10 m")
111,131 of 121,064 parcels vanish at -10 m
This can be useful for boundary offsets, but a polygon disappears completely when the negative distance is more than half its width. Most Manchester parcels are house plots less than 20 m across, so a 10 m inward buffer erased 111,131 of the 121,064.
Internal links
- How to Read a Shapefile in Python with GeoPandas
- How to Export GeoJSON in Python with GeoPandas
- How to Reproject Spatial Data in Python (GeoPandas)
- Siting mask removes everything — what a 500-degree buffer does
FAQ
Can I create buffers in GeoPandas with latitude and longitude coordinates?
Yes, but the distance will be interpreted in degrees if the CRS is geographic. For most spatial analysis, reproject to a projected CRS first.
What units does GeoPandas use for buffer distance?
GeoPandas uses the units of the current CRS. If the CRS uses meters, a buffer of 500 means 500 meters. If the CRS uses feet, it means 500 feet.
Can I buffer points, lines, and polygons with the same method?
Yes. The same .buffer() method works for all geometry types. The result is polygon geometry.
Why does my buffer output look too large or too small?
The most common cause is using an unsuitable CRS, especially EPSG:4326. Check the CRS and reproject to a local projected system before buffering.
How do I merge overlapping buffers into a single polygon?
Dissolve them with union_all() on the buffered geometry, for example merged = gdf.geometry.buffer(500).union_all(). This combines all overlapping circles or corridors into one continuous polygon instead of many overlapping shapes.
Can I use a different buffer distance per feature?
Yes. Pass a column of distances instead of a single number: gdf.geometry.buffer(gdf["radius_m"]). Each feature is buffered by its own value, which is useful for variable service areas or noise zones.
Why does my buffered circle look like a polygon with flat edges?
buffer() approximates curves with straight segments. Increase the segment count with the resolution parameter, for example gdf.geometry.buffer(500, resolution=32), to make point buffers look rounder at the cost of more vertices.
How do I get square or flat-ended buffers instead of rounded ones?
Use the cap_style and join_style parameters. For flat ends on line buffers, gdf.geometry.buffer(100, cap_style="flat") avoids the rounded caps that the default produces.