Report Progress and Cancellation in a Processing Algorithm
A progress bar that sits at zero for four minutes and then jumps to done is worse than no progress bar, because the user concludes the tool has hung and kills it. A Cancel button that does nothing is worse still. Both are the same object — the feedback passed into processAlgorithm() — and using it properly takes about four lines.
This recipe belongs to Processing Provider Plugins. It covers reporting progress accurately, checking for cancellation at the right frequency, logging in a way that helps rather than floods, splitting a bar across several phases, and passing feedback into algorithms you call from inside your own.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- An algorithm with parameters — see Add Parameters to a Processing Algorithm.
- A dataset large enough that progress is visible; a few thousand features is plenty.
The standard loop
from qgis.core import QgsProcessingException
def processAlgorithm(self, parameters, context, feedback):
source = self.parameterAsSource(parameters, "INPUT", context)
if source is None:
raise QgsProcessingException(self.invalidSourceError(parameters, "INPUT"))
total = 100.0 / source.featureCount() if source.featureCount() else 0
for current, feature in enumerate(source.getFeatures()):
if feedback.isCanceled():
break
self.process_one(feature)
feedback.setProgress(int(current * total))
return {"OUTPUT": dest_id}
Breakdown: This is the shape every built-in algorithm uses, and there are three details in it. The factor is computed once outside the loop, guarding against a zero feature count — a division by zero here is a surprisingly common crash on an empty layer. isCanceled() is checked at the top of each iteration and break exits cleanly, which is the whole cancellation contract: the framework never interrupts your code, it only sets a flag, so an algorithm that does not check simply cannot be cancelled. setProgress() takes a number from 0 to 100, and passing the raw feature index instead of the percentage produces a bar that fills in the first hundred features and then sits still.
Breaking rather than raising on cancellation matters: a cancelled run should return whatever it has, or nothing, without an error dialog. The framework knows the run was cancelled and reports it appropriately.
Log usefully, not constantly
feedback.pushInfo(self.tr(f"Processing {source.featureCount()} parcels"))
skipped = 0
for current, feature in enumerate(source.getFeatures()):
if feedback.isCanceled():
break
if not feature.hasGeometry():
skipped += 1
continue
...
if skipped:
feedback.pushWarning(
self.tr(f"{skipped} features had no geometry and were skipped"))
Breakdown: One line at the start saying what is about to happen, one at the end summarising anything unusual, and nothing per feature. A message inside the loop is the fastest way to make an algorithm slow — the log widget has to render every line — and it buries the two messages that matter under fifty thousand that do not. Counting exceptions and reporting the total afterwards gives the user a usable summary; pushWarning() styles it so it stands out, while pushInfo() is for ordinary narration. For genuinely fatal problems, raise QgsProcessingException with a message rather than logging and continuing.
Split progress across phases
Most real algorithms have several stages, and a single bar covering all of them is more honest when it is divided deliberately.
feedback.setProgressText(self.tr("Building the index"))
index = QgsSpatialIndex(source.getFeatures())
feedback.setProgress(20)
feedback.setProgressText(self.tr("Matching parcels"))
step = 60.0 / source.featureCount()
for current, feature in enumerate(source.getFeatures()):
if feedback.isCanceled():
break
match(feature, index)
feedback.setProgress(20 + int(current * step))
feedback.setProgressText(self.tr("Writing output"))
write_results(sink)
feedback.setProgress(100)
Breakdown: Allocating a share of the bar to each phase — 20 percent for the index, 60 for the matching, 20 for writing — gives a bar that moves at a roughly constant rate instead of one that stalls at 90 percent for a minute. setProgressText() puts a label above the bar, and naming the current phase is often more reassuring than the percentage: "Matching parcels" tells the user the tool is doing something specific. Estimate the shares from a real run rather than guessing; the point is that the bar's speed should not vary wildly, not that the split is exact.
Pass feedback into nested algorithms
An algorithm that calls other algorithms should hand the feedback down, or the bar freezes while the inner one runs.
import processing
from qgis.core import QgsProcessingMultiStepFeedback
steps = 3
multi = QgsProcessingMultiStepFeedback(steps, feedback)
multi.setCurrentStep(0)
buffered = processing.run("native:buffer", {...},
context=context, feedback=multi, is_child_algorithm=True)
multi.setCurrentStep(1)
clipped = processing.run("native:clip", {...},
context=context, feedback=multi, is_child_algorithm=True)
multi.setCurrentStep(2)
dissolved = processing.run("native:dissolve", {...},
context=context, feedback=multi, is_child_algorithm=True)
Breakdown: QgsProcessingMultiStepFeedback wraps the real feedback and maps each child algorithm's 0-to-100 onto its slice of the overall bar, so three steps each fill a third — this is exactly what the graphical modeller does internally. Passing context keeps temporary layers alive for the duration of the parent run; omitting it is why chained outputs sometimes vanish before the next step reads them. is_child_algorithm=True suppresses the child's own result handling, so intermediate outputs are not added to the map. Cancellation propagates through the wrapper without any extra work. This composition is the same one covered in Chaining Processing Algorithms.
Check cancellation often enough — and not too often
isCanceled() is cheap but not free, and the right frequency depends on the work per item.
For a loop doing milliseconds of work per feature, checking every iteration is correct and costs nothing measurable. For a tight loop doing microseconds of work over millions of items, check every few thousand:
for current, feature in enumerate(source.getFeatures()):
if current % 1000 == 0:
if feedback.isCanceled():
break
feedback.setProgress(int(current * total))
cheap_operation(feature)
Breakdown: Batching the check and the progress update together keeps the loop tight while still responding within a fraction of a second — a thousand cheap iterations is far less than the time a user takes to notice the button did nothing. What you must not do is check only between phases: an algorithm whose single long phase takes four minutes cannot be cancelled during it, and the user will kill QGIS instead, losing whatever else they had open. Where a single operation genuinely cannot be interrupted, say so with setProgressText() before starting it.
QGIS version compatibility
| QGIS version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | setProgress, isCanceled, pushInfo, pushWarning and multi-step feedback all present. |
| 3.28 LTR | 3.9 | Identical. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Identical; pushFormattedMessage adds styled output in newer releases. |
The spelling is isCanceled with one "l" throughout the API, following the C++ source. isCancelled does not exist and will raise an AttributeError at exactly the wrong moment.
Troubleshooting
- The bar jumps from 0 to 100.
setProgress()is being passed a count rather than a percentage, or is outside the loop. - Cancel does nothing.
isCanceled()is never checked, or only checked between phases. - The algorithm is slower than the same code outside Processing. Logging inside the loop. Move it out and count instead.
- The bar freezes during a nested algorithm. Feedback was not passed to the child. Use
QgsProcessingMultiStepFeedback. - Intermediate layers disappear between steps.
contextwas not passed toprocessing.run(). AttributeError: isCancelled. One "l". It isisCanceled.
Conclusion
Compute the progress factor once, check isCanceled() at the top of the loop and break when it is set, and call setProgress() with a percentage. Log once at the start, once at the end, and never per feature. Split the bar into weighted phases with setProgressText() naming each one, and wrap nested algorithms in QgsProcessingMultiStepFeedback so the bar keeps moving and Cancel keeps working all the way down.
Frequently Asked Questions
Does the framework interrupt my algorithm when Cancel is pressed? No. It sets a flag and waits for you to notice. Code that never checks cannot be cancelled.
Should a cancelled run raise an exception? No — break out of the loop and return. The framework reports the cancellation itself; an exception produces a misleading error.
Can I report progress from a background thread? The feedback object is designed for it and marshals to the interface thread safely. Do not touch any widget directly from the algorithm.
How do I show an indeterminate bar?
Do not call setProgress() at all and use setProgressText() to describe the phase. A bar that never moves with a label that changes is honest; a fake percentage is not.
Does pushInfo() appear in batch mode?
Yes, in the batch log, and in the command line runner's output — which is another reason to keep it to a few meaningful lines.
What is the difference between feedback and a QgsTask's progress?
They serve the same purpose in different frameworks. An algorithm run from the toolbox is already off the interface thread; a plugin doing its own background work uses QgsTask, as in Run a Background Task with QgsTask in PyQGIS.