PyQGIS Processing Output Is Empty: How to Fix It

Problem statement

processing.run() returns without an error, the output file exists, and it contains nothing:

result = processing.run("native:clip", {
    "INPUT": "data/raw/parcels.gpkg",
    "OVERLAY": "data/ref/city.gpkg",
    "OUTPUT": "data/out/clipped.gpkg",
})

layer = QgsVectorLayer(result["OUTPUT"], "clipped", "ogr")
print(layer.featureCount())      # 0

Zero features is a legitimate result β€” the algorithm did what you asked and nothing matched. The question is always why nothing matched, and in practice it is one of a short list of causes.

Common causes:

  • the two layers are in different CRSs, so they do not overlap in coordinate space
  • the input layer failed to load and was silently treated as empty
  • a selection was active and Use only selected features was on
  • an expression or filter parameter excluded everything
  • the predicate is wrong: within where you meant intersects
  • a buffer distance in degrees where the data is in metres (or the reverse)
  • the output was read before the algorithm's context was torn down, or after

Quick answer

When an algorithm produces zero features:

  1. check the inputs are valid and non-empty before running
  2. print both CRSs and both extents β€” a mismatch is the most common cause
  3. reproject to a common CRS explicitly rather than relying on the algorithm
  4. attach a QgsProcessingFeedback and read the messages the algorithm emits
  5. verify the output immediately and fail loudly on zero
import processing
from qgis.core import QgsVectorLayer, QgsProcessingFeedback

src = QgsVectorLayer("data/raw/parcels.gpkg", "parcels", "ogr")
ovl = QgsVectorLayer("data/ref/city.gpkg", "city", "ogr")

for name, lyr in (("input", src), ("overlay", ovl)):
    print(f"{name}: valid={lyr.isValid()} n={lyr.featureCount()} "
          f"crs={lyr.crs().authid()} extent={lyr.extent().toString(1)}")

feedback = QgsProcessingFeedback()
result = processing.run("native:clip",
                        {"INPUT": src, "OVERLAY": ovl, "OUTPUT": "data/out/clipped.gpkg"},
                        feedback=feedback)

out = QgsVectorLayer(result["OUTPUT"], "clipped", "ogr")
if out.featureCount() == 0:
    raise SystemExit("clip produced 0 features β€” check CRS and extents above")

Printing the CRS and extent of both inputs answers the question outright in most cases: two extents with no numbers in common cannot overlap.

Why an overlay returns nothing

Two layers in different CRSs shown as non-overlapping extents, then aligned after reprojection.
Degrees and metres put the same place in two different parts of the coordinate plane.

Step-by-step solution

Triage table of causes of empty Processing output and their fixes.
Seven reasons an algorithm returns nothing, and how to tell them apart.

Validate the inputs first

An invalid layer passed as INPUT is often treated as an empty source rather than an error, so the algorithm succeeds and produces nothing.

def require_layer(uri: str, name: str) -> QgsVectorLayer:
    layer = QgsVectorLayer(uri, name, "ogr")
    if not layer.isValid():
        raise RuntimeError(f"{name}: invalid layer {uri!r} β€” "
                           f"{layer.dataProvider().error().message()}")
    if layer.featureCount() == 0:
        raise RuntimeError(f"{name}: layer loaded but has 0 features")
    return layer

Two checks, and a whole class of confusion is gone. Note that featureCount() returns -1 for an invalid layer, so check validity first.

Compare CRS and extent

def describe(layer) -> str:
    e = layer.extent()
    return (f"{layer.name()}: {layer.featureCount()} features, {layer.crs().authid()}, "
            f"x[{e.xMinimum():.1f}, {e.xMaximum():.1f}] y[{e.yMinimum():.1f}, {e.yMaximum():.1f}]")

print(describe(src))
print(describe(ovl))

An extent like x[-3.3, -3.0] y[55.8, 56.0] is degrees; x[320000, 340000] y[670000, 680000] is a projected system. Two layers with extents of different orders of magnitude cannot intersect no matter what the algorithm does.

Reproject explicitly

Some algorithms reproject on the fly, some do not, and the ones that do use the project's CRS β€” which in a standalone script may be unset.

import processing

def to_crs(layer, authid: str):
    if layer.crs().authid() == authid:
        return layer
    res = processing.run("native:reprojectlayer", {
        "INPUT": layer, "TARGET_CRS": authid, "OUTPUT": "TEMPORARY_OUTPUT",
    })
    return res["OUTPUT"]

TARGET = "EPSG:27700"
src_p = to_crs(src, TARGET)
ovl_p = to_crs(ovl, TARGET)
result = processing.run("native:clip", {"INPUT": src_p, "OVERLAY": ovl_p,
                                        "OUTPUT": "data/out/clipped.gpkg"})

Choose a projected CRS appropriate to the area when the operation involves distances β€” buffers and clips in EPSG:4326 measure in degrees, which is almost never what you meant.

Read the feedback

QgsProcessingFeedback receives the progress and messages the algorithm would print in the GUI log β€” including "N features skipped" style warnings that explain an empty result.

from qgis.core import QgsProcessingFeedback

class Verbose(QgsProcessingFeedback):
    def pushInfo(self, info):       print("INFO :", info)
    def pushWarning(self, warning): print("WARN :", warning)
    def reportError(self, error, fatalError=False):
        print("ERROR:", error)
    def setProgress(self, p):       pass

result = processing.run(alg_id, params, feedback=Verbose())

Geometry errors, skipped features and CRS notices all arrive here, and none of them raise.

Check for an active selection

Use only selected features is a Processing setting, and if a selection exists it silently narrows every input.

from qgis.core import QgsProcessingContext

context = QgsProcessingContext()
context.setFlags(QgsProcessingContext.Flags())   # clear "use selection only"

print("selected in src:", src.selectedFeatureCount())
src.removeSelection()

result = processing.run(alg_id, params, context=context, feedback=feedback)

In a standalone script there is usually no selection, but a script that loads a .qgz project inherits whatever was saved with it.

Question the predicate and the parameters

Spatial predicates are not interchangeable, and the enum values are integers.

import processing
processing.algorithmHelp("native:joinattributesbylocation")

For native:joinattributesbylocation the PREDICATE list maps to: 0 intersects, 1 contains, 2 equals, 3 touches, 4 overlaps, 5 within, 6 crosses. Asking for within when point features sit exactly on a boundary, or when polygons only partially overlap, legitimately returns nothing.

# permissive first β€” confirm there is any spatial relationship at all
params["PREDICATE"] = [0]        # intersects

Start permissive, confirm you get rows, then tighten.

Verify the output where it actually lives

A TEMPORARY_OUTPUT is an identifier inside the processing context, not a file. Reading it after the context has gone gives you nothing.

from qgis.core import QgsProcessingContext

context = QgsProcessingContext()          # keep a reference for the whole run
result = processing.run(alg_id, {"INPUT": src, "OUTPUT": "TEMPORARY_OUTPUT"},
                        context=context, feedback=feedback)

out = context.getMapLayer(result["OUTPUT"])
print(out.featureCount() if out else "layer not in context")

When the result must survive the run, write to a real path instead β€” OUTPUT: "data/out/result.gpkg".

Code examples

Example 1: a guarded run helper

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

class Collector(QgsProcessingFeedback):
    def __init__(self):
        super().__init__()
        self.messages = []
    def pushInfo(self, info):        self.messages.append(("info", info))
    def pushWarning(self, warning):  self.messages.append(("warn", warning))
    def reportError(self, error, fatalError=False):
        self.messages.append(("error", error))

def run_checked(alg_id: str, params: dict, expect_features: bool = True):
    context, feedback = QgsProcessingContext(), Collector()
    result = processing.run(alg_id, params, context=context, feedback=feedback)

    out_ref = result.get("OUTPUT")
    layer = context.getMapLayer(out_ref) if out_ref else None
    if layer is None and isinstance(out_ref, str):
        layer = QgsVectorLayer(out_ref, "output", "ogr")

    if layer is None or not layer.isValid():
        raise RuntimeError(f"{alg_id}: no valid output ({out_ref!r})\n" +
                           "\n".join(f"  {k}: {v}" for k, v in feedback.messages))

    if expect_features and layer.featureCount() == 0:
        raise RuntimeError(
            f"{alg_id} produced 0 features.\n"
            f"  params: { {k: str(v)[:60] for k, v in params.items()} }\n"
            + "\n".join(f"  {k}: {v}" for k, v in feedback.messages)
        )
    return layer, result

Example 2: a pre-flight overlap check

from qgis.core import QgsCoordinateTransform, QgsProject

def extents_overlap(a, b) -> bool:
    """True if b's extent, transformed into a's CRS, intersects a's extent."""
    if a.crs() == b.crs():
        return a.extent().intersects(b.extent())
    tr = QgsCoordinateTransform(b.crs(), a.crs(), QgsProject.instance())
    return a.extent().intersects(tr.transformBoundingBox(b.extent()))

if not extents_overlap(src, ovl):
    raise SystemExit(
        f"inputs do not overlap: {src.crs().authid()} {src.extent().toString(1)} vs "
        f"{ovl.crs().authid()} {ovl.extent().toString(1)}"
    )

Running this before an expensive overlay saves both the run time and the puzzled inspection afterwards.

Example 3: buffer distances in the right units

import processing

# EPSG:4326 β€” DISTANCE is in degrees; 50 would be about 5,500 km
res = processing.run("native:buffer", {
    "INPUT": src, "DISTANCE": 0.00045, "SEGMENTS": 8, "OUTPUT": "TEMPORARY_OUTPUT",
})

# projected CRS β€” DISTANCE is in metres, which is what you meant
src_m = processing.run("native:reprojectlayer", {
    "INPUT": src, "TARGET_CRS": "EPSG:27700", "OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]
res = processing.run("native:buffer", {
    "INPUT": src_m, "DISTANCE": 50, "SEGMENTS": 8, "OUTPUT": "data/out/buffered.gpkg",
})

A degrees buffer rarely produces zero features β€” it produces absurd ones, which then clip everything or nothing downstream.

Example 4: prove the geometry relationship without Processing

When an overlay returns nothing, a direct geometry check tells you whether the data really does relate.

from qgis.core import QgsCoordinateTransform, QgsProject

tr = QgsCoordinateTransform(ovl.crs(), src.crs(), QgsProject.instance())
boundary = next(ovl.getFeatures()).geometry()
boundary.transform(tr)

hits = sum(1 for f in src.getFeatures() if f.geometry().intersects(boundary))
print(f"{hits} of {src.featureCount()} features intersect the boundary")

If this reports zero, the algorithm was right and the data is the problem: wrong extent, wrong area, or wrong CRS metadata on one of the layers.

Explanation

Processing algorithms are deliberately quiet. They validate parameters, do the work, and report progress; producing no features is a valid outcome, not an exception. So an empty output means the algorithm's own logic found nothing to emit β€” and the causes cluster into three groups: the input was not what you think, the geometries do not relate the way you think, or the parameters do not mean what you think.

Vertical steps: validate inputs, compare CRS, align, run with feedback, assert output.
Five checks in order β€” each one eliminates a group of causes before the expensive step.

CRS mismatch is the largest group by a wide margin, and it is worth understanding why it is invisible. In the QGIS GUI, layers are drawn in the project CRS and reprojected on the fly, so two layers in different systems appear neatly stacked on the canvas. Processing algorithms mostly operate on the raw coordinates. A layer in EPSG:4326 occupies roughly Β±180 by Β±90; the same area in EPSG:27700 occupies hundreds of thousands of metres. Their bounding boxes have nothing in common, so a clip or a spatial join finds no candidates at all β€” and reports success.

The second group is silent input failure. QgsVectorLayer returns an invalid object rather than raising, and passing an invalid layer where a source is expected can be interpreted as "no features". That produces exactly the same symptom as a real overlay miss, which is why the validity check belongs before the run rather than after.

The third group is parameter semantics. Enums are integers whose meaning you cannot see from the call; DISTANCE is in layer units, not metres; PREDICATE distinctions like within versus intersects are exactly what you are testing when you compare two layers. algorithmHelp() and the History panel both show what the GUI would have sent, and comparing your dictionary against that is usually a one-minute answer.

The general lesson is that an automated pipeline needs post-conditions. Deciding in advance that "this step must produce more than zero features" and asserting it converts a silent, weeks-long data problem into an immediate, specific failure.

Edge cases or notes

  • featureCount() may be -1: That means invalid, not empty. Check isValid() first.
  • On-the-fly reprojection is a GUI behaviour: Do not assume an algorithm will align CRSs for you. Reproject explicitly in scripts.
  • A .prj-less shapefile has no CRS: The layer loads, crs().isValid() is False, and overlays behave unpredictably. Set the CRS before use.
  • Geographic buffers: DISTANCE is in layer units. In EPSG:4326 that is degrees, so buffer in a projected CRS or use native:buffer after reprojection.
  • Invalid geometries are skipped: Some algorithms drop features that fail a validity check and note it in feedback. Run native:fixgeometries first if the count is suspiciously low.
  • TEMPORARY_OUTPUT lifetime: The layer lives in the QgsProcessingContext. Keep the context alive, or write to a real path.
  • Empty is sometimes correct: A clip against a boundary the data genuinely does not touch should return nothing. The assertion is there to make you look, not to declare a bug.

FAQ

Why does the algorithm succeed but return zero features?

Because zero is a valid result. The usual reasons are a CRS mismatch between inputs, an invalid input layer treated as empty, or a predicate that is stricter than you intended.

How do I check whether two layers actually overlap?

Print both CRSs and extents, and transform one bounding box into the other's CRS with QgsCoordinateTransform.transformBoundingBox() before testing intersects(). Extents in different orders of magnitude are the tell.

Do Processing algorithms reproject automatically?

Not reliably, and not in a standalone script where the project CRS may be unset. Reproject both inputs explicitly with native:reprojectlayer before an overlay.

Why did my buffer produce nothing useful?

DISTANCE is in the layer's units. In EPSG:4326 that means degrees, so 50 is an enormous distance and 0.0005 is roughly 50 metres. Reproject to a projected CRS and use metres.

How do I see the messages an algorithm logs?

Pass a QgsProcessingFeedback subclass that overrides pushInfo, pushWarning and reportError. These carry the notes about skipped or invalid features that explain most empty results.

Where does TEMPORARY_OUTPUT actually go?

Into the QgsProcessingContext you passed β€” or an internal one if you passed none. Retrieve it with context.getMapLayer(result["OUTPUT"]) and keep the context alive, or write to a file path instead.

Should an empty output fail my pipeline?

Usually yes. Assert on the feature count and raise, so an upstream data problem surfaces on the day it happens rather than when someone opens the map weeks later.