Chain Buffer and Clip in PyQGIS

Buffering a vector layer and then clipping the result to a study area is one of the most common two-step operations in GIS. Done by hand it is two toolbox dialogs, two intermediate files, and two manual checks. Done in PyQGIS it is a single script: run native:buffer, pass its output straight into native:clip using a temporary intermediate, then write only the final layer to disk. This page is a focused, runnable recipe for exactly that chain, and it builds on the general patterns in Chaining Processing Algorithms in PyQGIS.

The goal is a script you can paste into the QGIS Python Console, point at your data, and trust: it reports progress, validates inputs and outputs, and never leaves a stray buffer file behind because the intermediate lives in memory.

Prerequisites

  • QGIS 3.34 LTR (Python 3.12) recommended; the script also runs on 3.28 LTR and the 3.40/3.44 line.
  • A source vector layer (points, lines, or polygons) and an overlay polygon layer defining the area to keep.
  • Both layers in the same CRS, or a destination CRS set on the context. Buffer distances are in the layer's map units, so a projected CRS in metres is strongly preferred.
  • The processing module available — automatic in the QGIS Python Console.

If you have never invoked a single algorithm from code, read running a processing algorithm from a script first; this page assumes you can make one processing.run() call and chains two of them.

Step 1: Validate Inputs Before Running Anything

A chain that fails on step two after a slow step one wastes time. Validate both layers and warn on geographic CRS up front.

from qgis.core import QgsVectorLayer


def load_and_check(path: str, name: str) -> QgsVectorLayer:
    layer = QgsVectorLayer(path, name, "ogr")
    if not layer.isValid():
        raise ValueError(f"Could not load {name}: {path}")
    if layer.featureCount() == 0:
        raise ValueError(f"{name} has no features: {path}")
    if layer.crs().isGeographic():
        print(f"WARNING: {name} is in a geographic CRS; buffer distance is in "
              f"degrees, not metres. Reproject to a projected CRS first.")
    return layer

Breakdown: QgsVectorLayer(path, name, "ogr") opens the file through the OGR provider. isValid() catches bad paths and unsupported formats; an empty featureCount() catches a file that loads but contains nothing to process. The crs().isGeographic() check is the single most useful guard for buffering — a DISTANCE of 250 against EPSG:4326 buffers by 250 degrees, which silently produces nonsense rather than an error.

Step 2: Buffer into a Temporary Output

Run native:buffer and request 'TEMPORARY_OUTPUT' so the framework keeps the result without writing a file you would have to delete.

import processing
from qgis.core import QgsProcessingContext, QgsProcessingFeedback, QgsProject


def buffer_layer(source, distance_m, context, feedback):
    feedback.pushInfo(f"Buffering by {distance_m} m ...")
    result = processing.run("native:buffer", {
        "INPUT": source,
        "DISTANCE": distance_m,
        "SEGMENTS": 8,
        "END_CAP_STYLE": 0,      # 0 = round
        "JOIN_STYLE": 0,         # 0 = round
        "MITER_LIMIT": 2,
        "DISSOLVE": False,
        "OUTPUT": "TEMPORARY_OUTPUT",
    }, context=context, feedback=feedback)
    return result["OUTPUT"]

Breakdown: Every key here is a real native:buffer parameter. SEGMENTS controls how many line segments approximate each quarter-circle (8 is a good default). DISSOLVE=False keeps one buffer polygon per input feature; set it True to merge overlaps. The return value result["OUTPUT"] is a QgsVectorLayer held in memory — it is what step three consumes, never a path on disk.

Step 3: Clip the Buffer to the Study Area

Feed the buffer's output straight into native:clip as its INPUT, using the study-area polygon as the OVERLAY. This is the join that makes it a chain.

def clip_to_area(buffered_layer, overlay, out_path, context, feedback):
    feedback.pushInfo("Clipping buffer to study area ...")
    result = processing.run("native:clip", {
        "INPUT": buffered_layer,     # the OUTPUT from native:buffer
        "OVERLAY": overlay,
        "OUTPUT": out_path,          # final result -> permanent file
    }, context=context, feedback=feedback)
    return result["OUTPUT"]

Breakdown: INPUT is the in-memory buffer layer object returned by step two — no filename is involved between buffer and clip. OVERLAY is the polygon layer that defines what to keep. Because this is the final step, OUTPUT is a real path (for example /data/buffer_clipped.gpkg), so the result is written to disk. If you would rather keep only features that intersect the area without trimming their geometry, swap native:clip for native:extractbylocation with PREDICATE: [0] (intersects). For the geometry-trimming behaviour described in the dedicated clip a vector layer in PyQGIS guide, native:clip is correct.

Step 4: Assemble and Run the Full Chain

The orchestrator wires the three steps together, shares one context and one feedback object, and validates the final output.

import processing
from qgis.core import (
    QgsVectorLayer,
    QgsProcessingContext,
    QgsProcessingFeedback,
    QgsProcessingException,
    QgsProject,
)


class ProgressFeedback(QgsProcessingFeedback):
    def setProgress(self, progress):
        print(f"\r  {progress:5.1f}%", end="", flush=True)

    def pushInfo(self, info):
        print(f"\n{info}")


def buffer_then_clip(source_path, overlay_path, out_path, distance_m):
    source = load_and_check(source_path, "source")
    overlay = load_and_check(overlay_path, "overlay")

    context = QgsProcessingContext()
    context.setProject(QgsProject.instance())
    feedback = ProgressFeedback()

    try:
        buffered = buffer_layer(source, distance_m, context, feedback)
        final = clip_to_area(buffered, overlay, out_path, context, feedback)
    except QgsProcessingException as exc:
        feedback.pushInfo(f"Pipeline failed: {exc}")
        raise

    # Validate the written result
    written = QgsVectorLayer(out_path, "result", "ogr")
    if not written.isValid():
        raise RuntimeError(f"Final output is not a valid layer: {out_path}")
    feedback.pushInfo(f"Done. {written.featureCount()} features written to {out_path}")
    return written


# Run in the QGIS Python Console:
# buffer_then_clip(
#     "/data/wells.gpkg",
#     "/data/catchment.gpkg",
#     "/data/wells_buffer_clipped.gpkg",
#     distance_m=500,
# )

Breakdown: One QgsProcessingContext and one ProgressFeedback are created once and threaded through both algorithms, so progress and any destination-CRS policy are consistent across the chain. The buffer's in-memory OUTPUT flows into the clip's INPUT with no disk write between them. Wrapping both runs in a single try/except QgsProcessingException makes a failure in either step report clearly and stop. The final block re-opens the written file with a fresh QgsVectorLayer and confirms validity and feature count — proof the chain produced real data, not an empty shell. This mirrors the shared-context discipline detailed in batch processing with PyQGIS.

Optional: Swap Clip for Extract by Location

native:clip trims geometry to the overlay boundary — a buffer that straddles the study-area edge comes out cut along that edge. Sometimes you instead want every whole feature whose buffer touches the area, with geometry untouched. That is native:extractbylocation.

def extract_intersecting(buffered_layer, reference, out_path, context, feedback):
    feedback.pushInfo("Extracting buffers intersecting the area ...")
    result = processing.run("native:extractbylocation", {
        "INPUT": buffered_layer,
        "PREDICATE": [0],          # 0 = intersects
        "INTERSECT": reference,
        "OUTPUT": out_path,
    }, context=context, feedback=feedback)
    return result["OUTPUT"]

Breakdown: PREDICATE is a list of integer codes (0 = intersects, 1 = contains, 2 = disjoint, and so on). With intersects, any buffer polygon that overlaps the INTERSECT reference is kept in full — no trimming. INPUT is still the buffer layer from step two, so the chain is identical up to the final operation; only the algorithm and the keep-vs-trim semantics differ. Choose native:clip for cookie-cutter geometry and native:extractbylocation for whole-feature selection.

QGIS Version Compatibility

The script targets QGIS 3.34 LTR (Python 3.12) as the baseline. Both native:buffer and native:clip have been stable native algorithms since the early 3.x series, so the chain runs unchanged across recent releases.

QGIS / PythonNotes
3.28 LTR / Py 3.9Fully supported. native:buffer parameter keys identical.
3.34 LTR / Py 3.12 (baseline)Recommended. Round-cap/round-join defaults as shown.
3.40 / 3.44 / Py 3.12Same parameters; some additional optional keys exist. Use "Copy as Python Command" to confirm.

END_CAP_STYLE, JOIN_STYLE, and MITER_LIMIT are integers in every version (0 = round, 1 = flat/miter, 2 = square/bevel depending on the parameter). When in doubt, configure the buffer dialog interactively and copy the exact parameter dictionary.

Troubleshooting

Buffer produces enormous or empty geometries. The source layer is in a geographic CRS (degrees). A DISTANCE of 500 then means 500 degrees. Reproject to a projected, metre-based CRS (for example native:reprojectlayer to a local UTM zone) before buffering, or set the distance in degrees deliberately.

Clip returns zero features. The buffer and overlay do not overlap, or they are in different CRSs so they appear not to intersect. Confirm source.crs() and overlay.crs() match, and check extents overlap with source.extent().intersects(overlay.extent()).

QgsProcessingException: Unknown parameter. A parameter key is misspelled or does not exist for that algorithm. Open the algorithm dialog, set it up, and use "Advanced > Copy as Python Command" to get the authoritative keys.

The output file is locked or cannot be overwritten. A previous run's layer is still loaded in QGIS, or another program (a GIS viewer, cloud-sync) holds the file. Remove the layer from the project or write to a fresh path, then retry.

Nothing prints during a long buffer. The default QgsProcessingFeedback is silent. Pass the ProgressFeedback subclass shown above so setProgress and pushInfo reach the console.

Conclusion

Chaining native:buffer into native:clip is the canonical small pipeline: two algorithms, one in-memory intermediate, one written result. The pattern scales — drop a dissolve or reproject step in the middle, or loop the whole function over many inputs — because the mechanics never change: capture result["OUTPUT"], hand it to the next step's INPUT, and write only at the end. Validate before you start and after you finish, share one context and feedback object, and you have a reliable, repeatable operation you can fold into any larger automation.

Frequently Asked Questions

Why use TEMPORARY_OUTPUT instead of writing the buffer to a file? The buffer is an intermediate you immediately consume. TEMPORARY_OUTPUT keeps it managed in memory, so the chain is faster and leaves no file to clean up. Only the clip — the final step — writes to disk.

Should I use native:clip or native:extractbylocation? Use native:clip when you want features trimmed to the overlay boundary. Use native:extractbylocation (predicate intersects) when you want whole features that touch the area, with their geometry unchanged.

How do I buffer by a field value instead of a fixed distance? Set DISTANCE to a QgsProperty.fromExpression('"radius_m"') to drive the distance from an attribute, or pass the field reference supported by the buffer parameter in your QGIS version. Confirm the exact form with "Copy as Python Command".

Can I run this on hundreds of files? Yes — wrap buffer_then_clip() in a loop over your inputs, reusing the validation and feedback pattern. See batch processing with PyQGIS for the iteration and error-isolation structure.