Buffer a Geometry in PyQGIS
Buffering is the workhorse of proximity analysis: a 50 m zone around a road, a 500 m catchment around a school, a negative buffer to shrink a parcel back from its boundary. The PyQGIS call is a single method, and almost every problem people hit with it comes down to one of three things — the distance being interpreted in the wrong units, the corner approximation being too coarse, or the input geometry being invalid.
This page is a focused recipe within Geometry Operations and Spatial Predicates in PyQGIS. It covers buffering a single geometry, the parameters that control the shape, buffering a whole layer with a per-feature distance, and the CRS discipline that keeps the result meaningful.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- A layer in a projected CRS whose units are metres. This is not optional advice — see the CRS section below.
- Valid input geometry. Run
geometry.isGeosValid()first if the data came from a CAD export or a hand-digitised source; Find and Fix Invalid Geometries covers the repair.
Buffer a single geometry
The method takes a distance and a segment count, and returns a new polygon.
from qgis.core import QgsProject
layer = QgsProject.instance().mapLayersByName("schools")[0]
geometry = next(layer.getFeatures()).geometry()
buffered = geometry.buffer(500.0, 12)
print(buffered.area(), "m²")
print(round(3.14159 * 500 ** 2), "m² for a true circle")
Breakdown: The first argument is the distance in map units — metres in a projected CRS. The second is the number of straight segments used to approximate each quarter circle, so 12 gives 48 segments around a full circle. The area comparison is the quickest way to see the approximation at work: at 12 segments the polygon comes within about 0.1 % of a true circle, which is more than enough for analysis and cheap to render.
A negative distance erodes rather than expands, which is useful for pulling a working area back from a boundary. Be aware it can return an empty geometry when the shrink exceeds the shape's half-width:
eroded = geometry.buffer(-20.0, 12)
if eroded.isEmpty():
print("the polygon is narrower than 40 m somewhere")
Breakdown: isEmpty() rather than isNull() is the right check here — the operation succeeded, it simply produced nothing. Skipping the check means downstream code receives an empty polygon whose area is zero and whose predicates are all false, which is very hard to trace back.
Control the shape: segments, caps and joins
The default rounded corners are not always wanted. A road corridor usually wants flat ends; a cadastral offset usually wants mitred corners. buffer() has an extended form for that.
from qgis.core import Qgis, QgsGeometry
corridor = geometry.buffer(
25.0, # distance
8, # segments per quarter circle
Qgis.EndCapStyle.Flat, # square off the ends
Qgis.JoinStyle.Miter, # sharp corners
2.0, # miter limit
)
Breakdown: EndCapStyle is Round (the default), Flat (stops exactly at the endpoint) or Square (extends by the buffer distance then squares off) — the distinction only affects line inputs. JoinStyle is Round, Miter or Bevel, and controls the outside of every corner. The miter limit caps how far a sharp corner is allowed to project; without it, a near-doubling-back vertex produces a long spike. On QGIS 3.28 these enums live on QgsGeometry (QgsGeometry.CapFlat); the Qgis. spelling is the current one.
Buffer a whole layer by an attribute
For a layer, the Processing algorithm is the right tool — it handles the iteration, writes the output, and accepts a data-defined distance so each feature can have its own.
import processing
from qgis.core import QgsProperty
params = {
"INPUT": "/data/roads.gpkg|layername=roads",
"DISTANCE": QgsProperty.fromExpression('"lanes" * 4.5'),
"SEGMENTS": 8,
"END_CAP_STYLE": 1, # 0 round, 1 flat, 2 square
"JOIN_STYLE": 0, # 0 round, 1 mitre, 2 bevel
"DISSOLVE": False,
"OUTPUT": "/data/output/road_corridors.gpkg",
}
processing.run("native:buffer", params)
Breakdown: Wrapping the distance in QgsProperty.fromExpression() is what turns a fixed buffer into a per-feature one — the same mechanism that drives data-defined symbol size. The style parameters are integer codes here rather than the Qgis. enums used by the geometry method, which is an inconsistency worth remembering. DISSOLVE: True merges all the buffers into a single feature, which is what you want for a "total area within reach" question and wrong for anything that needs to stay attributable to its source feature.
Get the units right
buffer() works in map units and has no idea what they mean. In EPSG:4326 a distance of 500 is five hundred degrees — roughly one and a half times around the planet. The result is not an error; it is a geometry covering the whole world, which is why this mistake often survives into production.
import processing
from qgis.core import QgsCoordinateReferenceSystem, QgsProject
layer = QgsProject.instance().mapLayersByName("schools")[0]
if layer.crs().isGeographic():
centre = layer.extent().center()
zone = int((centre.x() + 180) / 6) + 1
epsg = (32600 if centre.y() >= 0 else 32700) + zone
layer = processing.run("native:reprojectlayer", {
"INPUT": layer,
"TARGET_CRS": QgsCoordinateReferenceSystem(f"EPSG:{epsg}"),
"OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]
buffers = processing.run("native:buffer", {
"INPUT": layer, "DISTANCE": 500, "SEGMENTS": 8,
"OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]
Breakdown: isGeographic() is the guard — it is true for EPSG:4326 and every other lat/lon system, and false for anything metric. Deriving the UTM zone from the layer's own centre means the script works anywhere without a hard-coded EPSG code. Reprojecting to a temporary layer keeps the source untouched. The full treatment of this pattern is in Coordinate Reference Systems in PyQGIS.
Multi-ring buffers
Distance bands — under 100 m, 100 to 250 m, 250 to 500 m — are a common deliverable, and the naive approach of buffering three times leaves you with nested polygons that all overlap. Subtracting each ring from the next produces true, non-overlapping bands.
from qgis.core import QgsProject
layer = QgsProject.instance().mapLayersByName("schools")[0]
geometry = next(layer.getFeatures()).geometry()
bands, previous = [], None
for distance in (100, 250, 500):
ring = geometry.buffer(distance, 12)
bands.append(ring if previous is None else ring.difference(previous))
previous = ring
for distance, band in zip((100, 250, 500), bands):
print(f"up to {distance} m: {band.area():,.0f} m²")
Breakdown: Each iteration buffers to the full distance and then subtracts the previous, smaller buffer, so the band is a true annulus rather than a disc. Keeping previous as the un-subtracted buffer is what makes the next subtraction correct — subtracting the previous band instead would leave the inner discs in place. Because the bands do not overlap, their areas sum to the area of the largest buffer, which is a useful assertion to add in a test. For a whole layer, native:multiringconstantbuffer does the same thing in one call.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.28 LTR | 3.9 | Use QgsGeometry.CapFlat / QgsGeometry.JoinStyleMiter instead of the Qgis. enums. |
| 3.34 LTR | 3.12 | Baseline. Both enum spellings accepted. |
| 3.40 / 3.44 | 3.12 | Qgis.EndCapStyle and Qgis.JoinStyle are the documented forms; native:buffer parameters unchanged. |
The native:buffer algorithm ID and its parameter names have not changed across 3.x, so the Processing form is the more portable of the two.
Troubleshooting
- The buffer covers the entire world. The layer is in a geographic CRS and the distance was read as degrees. Reproject to a metric CRS first.
GEOSBuffererrors or an empty result. The input geometry is invalid. RunmakeValid()on it before buffering.- Corners have long spikes. A mitre join with no limit on a near-reversing vertex. Lower the miter limit or switch to
JoinStyle.Round. - The circle looks like a polygon. The segment count is too low. Raise it from the default; 8 to 12 is the usual range, and beyond 24 the extra vertices cost more than they show.
- A negative buffer returns nothing. The shrink distance exceeds half the narrowest width of the shape. Check with
isEmpty()and reduce the distance. - Buffers overlap where you wanted one shape. Set
DISSOLVE: Truein the Processing call, or union the results withQgsGeometry.unaryUnion().
Conclusion
buffer() is a one-line call whose correctness rests almost entirely on the inputs: a projected CRS so the distance means metres, valid geometry so GEOS can do the work, and a segment count and join style chosen for what the output is for. For whole layers, native:buffer with a QgsProperty distance gives per-feature control without a Python loop.
Frequently Asked Questions
Why is my buffer enormous? The layer is in a geographic CRS such as EPSG:4326, so the distance is being interpreted in degrees rather than metres. Reproject to a metric CRS — a UTM zone derived from the data extent is a safe default — before buffering.
What does the segments argument actually control? The number of straight line segments used to approximate each quarter of a circular corner. Eight gives 32 segments around a full circle, which is visually smooth and within a fraction of a percent of the true area.
Can each feature have a different buffer distance?
Yes. Pass QgsProperty.fromExpression('"width" / 2') as the DISTANCE parameter to native:buffer. The expression is evaluated per feature exactly like a data-defined symbol property.
Why does a negative buffer return an empty geometry?
Because the erosion distance is larger than half the width of the shape at its narrowest point, so nothing survives. This is a valid result rather than an error — test with isEmpty() before using it.