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 featureswas on - an expression or filter parameter excluded everything
- the predicate is wrong:
withinwhere you meantintersects - 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:
- check the inputs are valid and non-empty before running
- print both CRSs and both extents β a mismatch is the most common cause
- reproject to a common CRS explicitly rather than relying on the algorithm
- attach a
QgsProcessingFeedbackand read the messages the algorithm emits - 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
Step-by-step solution
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.
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. CheckisValid()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()isFalse, and overlays behave unpredictably. Set the CRS before use. - Geographic buffers:
DISTANCEis in layer units. In EPSG:4326 that is degrees, so buffer in a projected CRS or usenative:bufferafter reprojection. - Invalid geometries are skipped: Some algorithms drop features that fail a validity check and note it in feedback. Run
native:fixgeometriesfirst if the count is suspiciously low. TEMPORARY_OUTPUTlifetime: The layer lives in theQgsProcessingContext. 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.
Internal links
- How to Run QGIS Processing Algorithms from Python
- QGIS Processing "Algorithm Not Found" Error in Python: How to Fix It
- PyQGIS Layer Fails to Load (isValid() Returns False): How to Fix It
- Spatial Join Returns Empty Results in GeoPandas: How to Fix It
- How to Fix CRS Mismatch in GeoPandas
- How to Batch Process Layers in QGIS with PyQGIS
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.