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.

One object, four responsibilitiesThe feedback object passed into the algorithm carries progress percentages to the progress bar, informational and warning text to the log panel, and reports whether the user has pressed cancel. The same object is supplied by the batch runner and the command line runner, so an algorithm written against it behaves correctly in all three contexts.Everything the user sees during a run comes through herefeedbackpassed into your algorithmsetProgressthe bar, 0 to 100pushInfothe log panelpushWarningproblems worth seeingisCanceledyou must check it

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.

Two progress bars over the same runThe first bar reports nothing until the algorithm finishes, so the user cannot tell whether it is working or hung. The second divides the run into three weighted phases, twenty percent for indexing, sixty for matching and twenty for writing, and shows a label naming the current phase, so the bar advances at a steady rate throughout.The bar's job is to tell the user it is still workingno reportingnothing for four minutes, then doneweighted phasesindex 20%match 60%write 20%setProgressText names the phase — often more reassuring than the number

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.

How long Cancel takes to workWhen the user presses cancel, an algorithm checking every iteration stops almost immediately. One checking every thousand cheap iterations stops within a fraction of a second, which is imperceptible. One checking only between phases may not stop for minutes, and the user kills QGIS instead, losing everything else they had open.The button does nothing until your code looks at the flagCancel pressedevery iterationstops at onceevery 1000a fraction of a second — finebetween phasesminuteslong enough that the user kills QGIS instead

QGIS version compatibility

QGIS versionPythonNotes
3.22 LTR3.9setProgress, isCanceled, pushInfo, pushWarning and multi-step feedback all present.
3.28 LTR3.9Identical.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Identical; 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. context was not passed to processing.run().
  • AttributeError: isCancelled. One "l". It is isCanceled.

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.