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

Buffering a point, a line and a polygon. the same call, three different resultsPointa circleLinea corridorPolygona grown shapeA negative distance shrinks a polygon instead — and can erase thin ones entirely spatialworkflow.io
The distance is always in the units of the CRS — metres only in a projected one.

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.

Street map of central Manchester with two buffers around the same point: a dashed 1 km circle, and a narrower teal ellipse from a buffer written in degrees. Street map of central Manchester with two buffers around the same point: a dashed 1 km circle, and a narrower teal ellipse from a buffer written in degrees.
A degree-based buffer on real streets. At Manchester city centre (53.48°N), a “1 km” buffer written as 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 outputs a polygon: a circle around a point, a corridor around a line, and an expanded area around a polygon. Point → circle Line → corridor Polygon → expanded area spatialworkflow.io
A buffer always outputs a polygon: a circle around a point, a corridor around a line, and an expanded area around a polygon.
Buffer parameters that change the shape of the result. the arguments worth knowingwhat it controlswatch out fordistancesize, in CRS units✕degrees if unprojectedresolutionsegments per quarter circlehigher = smoother, heaviercap_styleround / flat / square endflat suits corridorsjoin_styleround / mitre / bevel cornermitre can spike outwardDefault resolution=16 is smooth; drop it when buffering millions of features spatialworkflow.io
Only the first one is usually wrong, and it is wrong by a factor of about 100 000.

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.

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.