PyQGIS Processing Output Is Empty: How to Fix It

On this page

Problem statement

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

import processing

result = processing.run("native:clip", {
    "INPUT": "data/raw/parcels.gpkg|layername=parcels",
    "OVERLAY": "data/ref/oakham_mislabelled.geojson",
    "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.

The list is short:

  • a layer's CRS label is wrong, so its coordinates are read as somewhere else
  • a selection was applied to an input β€” or not applied the way you meant
  • 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 data genuinely does not overlap

Two things that are often blamed are not on the list. A layer that is in a different CRS from the others, correctly labelled, is reprojected by the native algorithms; and an input that fails to load does not produce an empty output β€” it raises. Every case below was run on QGIS 4.2 with HM Land Registry's 21,253 Rutland parcels and the Oakham South ward.

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 β€” and ask whether the numbers fit the label
  3. fix a wrong label with setCrs(); let Processing reproject a right one
  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|layername=parcels", "parcels", "ogr")
ovl = QgsVectorLayer("data/ref/oakham_mislabelled.geojson", "oakham", "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(0)}")

feedback = QgsProcessingFeedback()
result = processing.run("native:clip",
                        {"INPUT": src, "OVERLAY": ovl, "OUTPUT": "TEMPORARY_OUTPUT"},
                        feedback=feedback)

out = result["OUTPUT"]
if out.featureCount() == 0:
    print("clip produced 0 features β€” check CRS and extents above")
input: valid=True n=21253 crs=EPSG:27700 extent=479001,292381 : 506878,319201
overlay: valid=True n=1 crs=EPSG:4326 extent=483986,306574 : 487631,309766
clip produced 0 features β€” check CRS and extents above

Printing the CRS and extent of both inputs answers the question outright here: the overlay claims to be EPSG:4326, but its coordinates are six-figure numbers that no longitude or latitude can be.

Why an overlay returns nothing

Clipping Rutland parcels by a ward with the overlay in the same CRS, in another CRS, and with a wrong CRS label. a different CRS is fine; a wrong label is notboth EPSG:27700clip: 2,028 parcelsoverlay in EPSG:4326reprojected: 2,019metres labelled 4326off the globeclip: 0 parcelsProcessing reprojects a correctly labelled overlay; it cannot fix a wrong label spatialworkflow.io
Measured on this page under QGIS 4.2: a different CRS is reprojected; a wrong label cannot be.

Inputs, CRS and extent, feedback, selections, predicates

Six reasons a Processing algorithm returns zero features, as measured on QGIS 4.2. zero features β€” which of these?CauseFixA wrong CRS label on a layerFix the label with setCrs()Selection passed by source stringPass layer.id() in the definitionSelected features only, on purposeClear the selection, or mean itThe predicate is too strictStart with intersects, thentightenDISTANCE in degrees, not metresBuffer in a projected CRSThe extents genuinely missThe result is right β€” check thedataA real CRS difference and a missing input are not here: one is handled, one raises spatialworkflow.io
Six reasons an algorithm returns nothing, and how to tell them apart.

Validate the inputs first

A path or layer that cannot be loaded does not give you an empty output β€” the algorithm refuses to run:

from qgis.core import QgsProcessingException

try:
    processing.run("native:clip", {"INPUT": "data/raw/parcel.gpkg", "OVERLAY": ovl,
                                   "OUTPUT": "TEMPORARY_OUTPUT"})
except QgsProcessingException as error:
    print("QgsProcessingException:", " ".join(str(error).split()))
QgsProcessingException: Unable to execute algorithm Could not load source layer for INPUT: data/raw/parcel.gpkg not found

What does run and produce nothing is a layer that loads and is empty, so check both before an expensive step:

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}")
    if layer.featureCount() == 0:
        raise RuntimeError(f"{name}: layer loaded but has 0 features")
    return layer

print(require_layer("data/raw/parcels.gpkg|layername=parcels", "parcels").featureCount(), "parcels")
21253 parcels

Compare CRS and extent β€” and read the numbers

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}]")

oakham_bng = QgsVectorLayer("data/ref/oakham_bng.geojson", "oakham (27700)", "ogr")
oakham_wgs84 = QgsVectorLayer("data/ref/oakham_wgs84.geojson", "oakham (4326)", "ogr")
for layer in (src, oakham_bng, oakham_wgs84, ovl):
    print(describe(layer))
parcels: 21253 features, EPSG:27700, x[479000.6, 506878.4] y[292380.6, 319200.7]
oakham (27700): 1 features, EPSG:27700, x[483986.4, 487630.9] y[306574.3, 309765.6]
oakham (4326): 1 features, EPSG:4326, x[-0.8, -0.7] y[52.7, 52.7]
oakham: 1 features, EPSG:4326, x[483986.4, 487630.9] y[306574.3, 309765.6]

The second and third overlays are the same ward in two CRSs, each correctly labelled; the last is the ward's metre coordinates with a 4326 label on them. An extent like x[-0.8, -0.7] y[52.6, 52.7] is degrees; x[484000, 488000] is a projected system. The label is only a claim β€” the numbers are the evidence.

Let Processing reproject β€” but only a correct label

for overlay in (oakham_bng, oakham_wgs84, ovl):
    clipped = processing.run("native:clip", {"INPUT": src, "OVERLAY": overlay,
                                             "OUTPUT": "TEMPORARY_OUTPUT"})["OUTPUT"]
    print(f"{overlay.name():18} β†’ {clipped.featureCount():,} parcels")

fixed = QgsVectorLayer("data/ref/oakham_mislabelled.geojson", "oakham (relabelled)", "ogr")
fixed.setCrs(QgsCoordinateReferenceSystem("EPSG:27700"))      # the coordinates really are BNG
clipped = processing.run("native:clip", {"INPUT": src, "OVERLAY": fixed, "OUTPUT": "TEMPORARY_OUTPUT"})["OUTPUT"]
print(f"{fixed.name():18} β†’ {clipped.featureCount():,} parcels")
oakham (27700)     β†’ 2,028 parcels
oakham (4326)      β†’ 2,019 parcels
oakham             β†’ 0 parcels
oakham (relabelled) β†’ 2,028 parcels

The native clip transformed the correctly labelled WGS 84 overlay into the input's CRS and found 2,019 parcels β€” not quite 2,028, because a boundary written out through WGS 84 and brought back is not exactly the same line, and a few parcels that only touch it fall the other side. The mislabelled overlay it transformed too β€” from what it was told was longitude and latitude β€” which sent the ward off the globe. setCrs() corrects a label without moving coordinates; native:reprojectlayer moves coordinates and keeps a label true. Mixing them up is the whole bug.

Choose a projected CRS appropriate to the area when the operation involves distances β€” buffers 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.

from qgis.core import QgsProcessingFeedback

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

feedback = Verbose()
processing.run("native:buffer", {"INPUT": oakham_wgs84, "DISTANCE": 50, "OUTPUT": "TEMPORARY_OUTPUT"},
               feedback=feedback)
print(feedback.lines[:3])
[('info', "Results: {'OUTPUT': 'Buffered_5326351d_423f_4c03_9358_e2555673da6b'}")]

Geometry errors, skipped features and CRS notices arrive here, and none of them raise. Just as telling is what does not arrive: a 50-degree buffer on a WGS 84 layer produced no warning at all.

Check how a selection is applied

A layer passed as INPUT is read whole β€” a selection on it is ignored. To use only the selected features you pass a QgsProcessingFeatureSourceDefinition, and how you name the layer in it matters:

from qgis.core import QgsProcessingFeatureSourceDefinition, QgsProject

src.selectByExpression("$area > 100000")
print("selected:", src.selectedFeatureCount())

def clip_count(input_value):
    return processing.run("native:clip", {"INPUT": input_value, "OVERLAY": oakham_bng,
                                          "OUTPUT": "TEMPORARY_OUTPUT"})["OUTPUT"].featureCount()

print("layer passed directly:              ", clip_count(src))
print("definition by source, selected only:", clip_count(QgsProcessingFeatureSourceDefinition(src.source(), selectedFeaturesOnly=True)))
QgsProject.instance().addMapLayer(src)
print("definition by layer id, selected:   ", clip_count(QgsProcessingFeatureSourceDefinition(src.id(), selectedFeaturesOnly=True)))
src.removeSelection()
selected: 926
layer passed directly:               2028
definition by source, selected only: 0
definition by layer id, selected:    17

By layer id, the selection applied: 17 large parcels in the ward. By source string, the algorithm opened the file afresh β€” a new layer with no selection β€” and "selected only" meant nothing, so the output was empty. A script that loads a .qgz project and runs algorithms on its layers by id inherits whatever selection was saved with them.

Clipping Rutland parcels by Oakham South with 926 parcels selected, passing the input three ways. 926 parcels selected β€” what did the clip see?INPUT passed asparcels outthe layer object2,028 β€” selection ignoreddefinition(source string, selected only)0 β€” a new layer, no selectiondefinition(layer id, selected only)17 β€” the selection appliedA selection belongs to a layer instance, not to a file spatialworkflow.io
Measured above under QGIS 4.2.

Question the predicate and the parameters

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

alg = QgsApplication.processingRegistry().algorithmById("native:joinattributesbylocation")
print(dict(enumerate(alg.parameterDefinition("PREDICATE").options())))

wards = QgsVectorLayer("data/ref/wards.geojson", "wards", "ogr")
for predicate, label in ((0, "intersect"), (5, "are within")):
    joined = processing.run("native:joinattributesbylocation", {
        "INPUT": src, "JOIN": wards, "PREDICATE": [predicate], "JOIN_FIELDS": ["WD24NM"],
        "METHOD": 1, "DISCARD_NONMATCHING": True, "OUTPUT": "TEMPORARY_OUTPUT"})["OUTPUT"]
    print(f"parcels that {label} a ward: {joined.featureCount():,}")
{0: 'intersect', 1: 'contain', 2: 'equal', 3: 'touch', 4: 'overlap', 5: 'are within', 6: 'cross'}
parcels that intersect a ward: 21,246
parcels that are within a ward: 20,275

Asking for are within drops every parcel that crosses a ward boundary β€” or runs over the county line β€” because it is not within any single ward. Start permissive, confirm you get rows, then tighten.

Verify the output where it actually lives

From processing.run, a TEMPORARY_OUTPUT comes back as the layer itself, so there is nothing to look up β€” keep a reference to it for as long as you use it. When the result must survive the run, write to a real path instead and check the file you wrote:

result = processing.run("native:clip", {"INPUT": src, "OVERLAY": oakham_bng, "OUTPUT": "data/out/oakham_parcels.gpkg"})
print(type(result["OUTPUT"]).__name__, result["OUTPUT"])
print(QgsVectorLayer(result["OUTPUT"], "check", "ogr").featureCount(), "features in the file")
str data/out/oakham_parcels.gpkg
2028 features in the file

Pre-flight checks on Rutland's parcels and wards

The data used on this page

HM Land Registry's INSPIRE Index Polygons for Rutland (Open Government Licence, Β© Crown copyright and database rights) as data/raw/parcels.gpkg, and Rutland's 15 ONS wards (December 2024 boundaries) as data/ref/wards.geojson. Oakham South ward is written three ways: in British National Grid (oakham_bng.geojson), reprojected to WGS 84 (oakham_wgs84.geojson), and with its British National Grid coordinates labelled EPSG:4326 (oakham_mislabelled.geojson) β€” what a file whose .prj was swapped, or a GeoJSON written without reprojecting, looks like. QGIS 4.2 runs headless.

import json
import os
from pathlib import Path

import requests
from osgeo import gdal

gdal.UseExceptions()
for folder in ("data/raw", "data/ref", "data/out"):
    Path(folder).mkdir(parents=True, exist_ok=True)
if not Path("data/raw/parcels.gpkg").exists():
    reply = requests.Session().get(
        "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300)
    reply.raise_for_status()
    Path("data/raw/Rutland.zip").write_bytes(reply.content)
    gdal.VectorTranslate("data/raw/parcels.gpkg", "/vsizip/data/raw/Rutland.zip/Land_Registry_Cadastral_Parcels.gml",
                         layerName="parcels", selectFields=["INSPIREID"])
if not Path("data/ref/wards.geojson").exists():
    Path("data/ref/wards.geojson").write_bytes(requests.get(
        "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
        "Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
        params={"where": "LAD24CD='E06000017'", "outFields": "WD24CD,WD24NM", "outSR": 27700, "f": "geojson"},
        timeout=120).content)
    gdal.VectorTranslate("data/ref/oakham_bng.geojson", "data/ref/wards.geojson", where="WD24NM = 'Oakham South'",
                         format="GeoJSON", layerCreationOptions=["RFC7946=NO"])
    gdal.VectorTranslate("data/ref/oakham_wgs84.geojson", "data/ref/oakham_bng.geojson", format="GeoJSON",
                         dstSRS="EPSG:4326", reproject=True)
    mislabelled = json.loads(Path("data/ref/oakham_bng.geojson").read_text())
    mislabelled.pop("crs", None)                              # GeoJSON without a crs member means WGS 84
    Path("data/ref/oakham_mislabelled.geojson").write_text(json.dumps(mislabelled))

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import QgsApplication, QgsCoordinateReferenceSystem, QgsVectorLayer, Qgis
qgs = QgsApplication([], False)
qgs.initQgis()
from processing.core.Processing import Processing
Processing.initialize()
print("QGIS", Qgis.QGIS_VERSION)
QGIS 4.2.2-BelΓ©m do ParΓ‘

Example 1: a guarded run helper

from qgis.core import QgsProcessingContext

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 = result.get("OUTPUT")
    layer = out if isinstance(out, QgsVectorLayer) else QgsVectorLayer(out, "output", "ogr")
    if not layer.isValid():
        raise RuntimeError(f"{alg_id}: no valid output ({out!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)[:40] for k, v in params.items()} }\n"
            + "\n".join(f"  {k}: {v}" for k, v in feedback.messages)
        )
    return layer, result

layer, _ = run_checked("native:clip", {"INPUT": src, "OVERLAY": oakham_bng, "OUTPUT": "TEMPORARY_OUTPUT"})
print(layer.featureCount(), "parcels")
try:
    run_checked("native:clip", {"INPUT": src, "OVERLAY": ovl, "OUTPUT": "TEMPORARY_OUTPUT"})
except RuntimeError as error:
    print("RuntimeError:", str(error).splitlines()[0])
2028 parcels
RuntimeError: native:clip produced 0 features.

Example 2: a pre-flight overlap check

from qgis.core import QgsCoordinateTransform, QgsCsException, QgsProject, QgsRectangle

def plausible_extent(layer) -> bool:
    """A geographic CRS cannot have coordinates outside Β±180 / Β±90."""
    return not layer.crs().isGeographic() or QgsRectangle(-180, -90, 180, 90).contains(layer.extent())

def extents_overlap(a, b):
    """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())
    try:
        return a.extent().intersects(tr.transformBoundingBox(b.extent()))
    except QgsCsException as error:
        return f"cannot transform: {error}"

for overlay in (oakham_wgs84, ovl):
    print(f"{overlay.name():14} plausible={plausible_extent(overlay)!s:5} overlaps={extents_overlap(src, overlay)}")
oakham (4326)  plausible=True  overlaps=True
oakham         plausible=False overlaps=cannot transform: Forward transform (EPSG:4326 to EPSG:27700) of bounding box failed: Invalid coordinate

Running this before an expensive overlay saves both the run time and the puzzled inspection afterwards. For the mislabelled ward the transform itself fails β€” PROJ refuses latitudes in the hundreds of thousands β€” which is the clearest message this whole problem ever produces, and one the clip swallowed.

Example 3: buffer distances in the right units

# EPSG:4326 β€” DISTANCE is in degrees
for distance in (50, 0.00045):
    buffered = processing.run("native:buffer", {"INPUT": oakham_wgs84, "DISTANCE": distance, "SEGMENTS": 8,
                                                "OUTPUT": "TEMPORARY_OUTPUT"})["OUTPUT"]
    print(f"DISTANCE={distance:<8} in degrees β†’ extent {buffered.extent().toString(3)}")

# projected CRS β€” DISTANCE is in metres, which is what you meant
buffered = processing.run("native:buffer", {"INPUT": oakham_bng, "DISTANCE": 50, "SEGMENTS": 8,
                                            "OUTPUT": "data/out/oakham_buffered.gpkg"})["OUTPUT"]
print("DISTANCE=50 in metres β†’ extent", QgsVectorLayer(buffered, "b", "ogr").extent().toString(0))
DISTANCE=50       in degrees β†’ extent -50.669,2.683 : 49.061,102.652
DISTANCE=0.00045  in degrees β†’ extent -0.760,52.650 : -0.705,52.679
DISTANCE=50 in metres β†’ extent 483937,306524 : 487681,309816

A degrees buffer rarely produces zero features β€” it produces absurd ones, here a ward buffered past latitude 90, which then clip everything or nothing downstream. And 0.00045Β° is not "about 50 m" in both directions: at Oakham's latitude it is 50 m north–south and 30 m east–west.

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.

boundary = next(oakham_wgs84.getFeatures()).geometry()
boundary.transform(QgsCoordinateTransform(oakham_wgs84.crs(), src.crs(), QgsProject.instance()))
hits = sum(1 for f in src.getFeatures() if f.geometry().intersects(boundary))
print(f"{hits:,} of {src.featureCount():,} parcels intersect Oakham South (from the WGS 84 copy of the ward)")
2,019 of 21,253 parcels intersect Oakham South (from the WGS 84 copy of the ward)

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.

The page's QGIS session ends here:

QgsProject.instance().clear()
qgs.exitQgis()
print("QGIS closed")
QGIS closed

Processing is quiet about results

Processing algorithms are deliberately quiet about results. They validate their inputs β€” an input that cannot be loaded raises β€” 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.

Five checks to run before and after a Processing algorithm. check before the expensive step1inputs valid and non-emptyisValid, featureCount2CRS and extent printeddo the numbers fit the label?3reproject to one CRSnative:reprojectlayer4run with feedback attachedread the messages5assert the output countzero fails the runA post-condition turns a silent data problem into an immediate failure spatialworkflow.io
Five checks in order β€” each one eliminates a group of causes before the expensive step.

CRS problems are the group people reach for first, and it is worth being precise about which CRS problem empties an output. A layer that is genuinely in another CRS, correctly labelled, is not one: the native overlay algorithms transform the second layer into the first layer's CRS, as the clip with the WGS 84 ward showed. A layer whose label is wrong is. Its coordinates are transformed from a CRS they are not in, land somewhere meaningless β€” sometimes outside the valid range altogether β€” and meet nothing. The GUI hides this less than it seems: the layer draws in the wrong place, or not at all, and the numbers in its extent do not fit its label.

The second group is selection and filtering. A layer passed directly is read whole; a QgsProcessingFeatureSourceDefinition with selectedFeaturesOnly applies the selection only when it names the layer instance that holds it.

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 are within versus intersect are exactly what you are testing when you compare two layers. The algorithm's parameter definitions 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.

When empty is an error, and when it is correct

  • An input that cannot load raises: Could not load source layer for INPUT is an exception, not an empty result.
  • A correct CRS difference is handled: the native overlay algorithms reproject the overlay; do not reproject by hand just in case.
  • A .prj-less shapefile has no CRS: The layer loads, crs().isValid() is False, and what an algorithm then assumes is not something to rely on. Set the CRS before use.
  • Geographic buffers: DISTANCE is in layer units. In EPSG:4326 that is degrees, and a degree of longitude is shorter than a degree of latitude away from the equator.
  • Invalid geometries: Depending on the context's invalid-geometry setting, an algorithm stops or skips them; either way the feedback says so. Run native:fixgeometries first if the count is suspiciously low.
  • Selections belong to a layer instance: a definition that names the file opens a new layer without the selection.
  • 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 layer whose CRS label does not match its coordinates, a selection applied in a way you did not intend, or a predicate that is stricter than you meant. A layer that cannot load is not one of them β€” Processing raises for that.

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?

The native overlay algorithms do: on QGIS 4.2 a clip with the overlay in EPSG:4326 and the input in EPSG:27700 gave the same 2,028 parcels as with both in 27700. What they cannot fix is a wrong label β€” metre coordinates labelled 4326 gave 0. Fix labels with setCrs(); reproject with native:reprojectlayer when you want the data itself in another CRS.

Why did my buffer produce nothing useful?

DISTANCE is in the layer's units. In EPSG:4326 that means degrees: 50 took the Oakham ward past latitude 90, and 0.00045 is 50 m north–south but only 30 m east–west at that latitude. 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?

From processing.run, it comes back as the layer object itself in result["OUTPUT"]; keep a reference to it while you use it. Write to a file path when the result must outlive the process.

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.