Show Progress and Support Cancellation in a QGIS Plugin

Users forgive slow. They do not forgive silent. A job that takes ninety seconds and shows a moving bar with a cancel button feels like a tool; the same ninety seconds with a frozen dialog feels like a fault. The mechanics are small — a signal, a bar, a flag checked in a loop — and the difference in how the plugin is perceived is enormous.

This recipe belongs to Background Tasks and Plugin Performance. It assumes the work is already on a QgsTask — see Run a Long Task with QgsTask in PyQGIS — and covers wiring progress to a widget, using the QGIS message bar instead of your own dialog, cancelling promptly, and leaving the interface in a sane state afterwards.

Three places to show that something is happeningThe task manager panel shows every running task automatically and costs no code. A message bar item across the top of the canvas suits a job started from a dialog the user then closes. A progress bar inside the plugin dialog suits work the user is watching directly. All three read the same progressChanged signal.One signal, three audiencestask.progressChangeddelivered on the main threadtask manager panelfree — no code at allcancel button includedeasy to miss in the cornermessage bar widgetacross the top of the canvassurvives closing the dialogbest default for pluginsbar in your dialogright where they are lookingmust handle the dialog closingmost code to get right

Prerequisites

  • QGIS 3.34 LTR or newer and a plugin that already runs its work on a QgsTask.
  • A dialog built in Qt Designer or in code — see Qt Designer for GIS Interfaces.
  • iface available in your plugin, which the standard plugin skeleton provides.

Drive a progress bar from the task

from qgis.PyQt.QtWidgets import QProgressBar
from qgis.core import QgsApplication

def start(self):
    self.task = AreaSumTask("Summing parcel areas", self.snapshot)

    self.dlg.progressBar.setRange(0, 100)
    self.dlg.progressBar.setValue(0)
    self.dlg.cancelButton.setEnabled(True)

    self.task.progressChanged.connect(self.on_progress)
    self.task.taskCompleted.connect(self.on_done)
    self.task.taskTerminated.connect(self.on_done)

    QgsApplication.taskManager().addTask(self.task)

def on_progress(self, value):
    self.dlg.progressBar.setValue(int(value))

def on_done(self):
    self.dlg.progressBar.setValue(100)
    self.dlg.cancelButton.setEnabled(False)

Breakdown: progressChanged carries a float between 0 and 100 and is emitted on the main thread, so updating a widget from the slot is safe. Connecting both taskCompleted and taskTerminated is the detail that gets missed: the first fires only on success, the second on failure or cancellation, and a handler attached to only one leaves the dialog stuck showing a running job forever. Setting the range explicitly to 0–100 avoids the "busy" indeterminate style that a default-constructed bar shows.

Prefer the message bar for long work

If the job outlives the dialog — and users close dialogs — putting the bar in the dialog is the wrong home for it. QGIS's message bar hosts widgets, including a progress bar and a cancel button.

from qgis.PyQt.QtWidgets import QProgressBar
from qgis.PyQt.QtCore import Qt
from qgis.core import Qgis

def start_with_message_bar(self):
    self.message_widget = iface.messageBar().createMessage("Summing parcel areas…")

    bar = QProgressBar()
    bar.setRange(0, 100)
    bar.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
    self.message_widget.layout().addWidget(bar)

    iface.messageBar().pushWidget(self.message_widget, Qgis.Info)

    self.task = AreaSumTask("Summing parcel areas", self.snapshot)
    self.task.progressChanged.connect(bar.setValue)
    self.task.taskCompleted.connect(self.clear_message)
    self.task.taskTerminated.connect(self.clear_message)
    QgsApplication.taskManager().addTask(self.task)

def clear_message(self):
    iface.messageBar().popWidget(self.message_widget)
    iface.messageBar().pushMessage("Done", "Areas summed", level=Qgis.Success, duration=5)

Breakdown: createMessage() returns an item whose layout accepts arbitrary widgets, so the progress bar sits inline with the text. pushWidget() keeps it visible until removed, unlike pushMessage() which fades. Connecting progressChanged straight to bar.setValue works because both are Qt objects on the main thread — no wrapper slot needed. Popping the widget in a handler wired to both completion signals prevents the message bar from accumulating stale progress items, which is what happens when only the success path cleans up.

Cancel promptly, not eventually

Cancellation is cooperative. The flag is set instantly; how quickly the work stops is entirely down to how often you look.

def run(self):
    count = self.source.featureCount() or 1
    for index, feature in enumerate(self.source.getFeatures()):
        if self.isCanceled():
            return False                      # checked every feature — stops in milliseconds
        self.process(feature)
        if index % 500 == 0:
            self.setProgress(100 * index / count)
    return True

Breakdown: The check is per feature while the progress update is every five hundred, and the asymmetry is deliberate: isCanceled() is a cheap atomic read, whereas setProgress() emits a cross-thread signal. If each iteration is itself slow — a Processing algorithm per feature, a network request — check inside that step too, by passing a feedback object whose isCanceled() is chained to the task's. Returning False is what tells the manager the task did not complete, which in turn is what fires taskTerminated rather than taskCompleted.

How long cancel takes to take effectThree bars measure the gap between the cancel press and the task stopping. Checking every feature stops almost immediately. Checking once every thousand features leaves a visible pause. Never checking means the job runs to completion and the cancel button did nothing at all.The flag is instant; the stop is only as fast as your loopcancel pressedevery featurestops at onceevery 1 000a noticeable pausenever checkedruns to the end — the button was decorationisCanceled() is a cheap atomic read — checking it every iteration costs nothing measurable

Leave the interface consistent

Whatever happens, the dialog has to end up in a state the user can act on. Three rules cover it.

Disable the start button while running. Two concurrent tasks writing the same output is a bug the user should not be able to trigger.

Re-enable everything from a single handler wired to both completion signals, so there is one place responsible for the post-run state rather than three that can disagree.

Guard against the dialog being gone. If the user closed it, the widgets may be deleted while the task still runs. Check before touching them:

from qgis.PyQt import sip

def on_progress(self, value):
    if self.dlg is None or sip.isdeleted(self.dlg):
        return
    self.dlg.progressBar.setValue(int(value))

Breakdown: sip.isdeleted() asks whether the underlying C++ object has been destroyed while the Python wrapper still exists — the exact situation created by a closed dialog, and the cause of "wrapped C/C++ object has been deleted" tracebacks appearing minutes after the user closed a window. Returning early is enough; the task continues and reports through the message bar or the log instead. Cancelling the task in the dialog's closeEvent is the stricter alternative when the work has no value without the dialog.

Report stages, not just a percentage

A bar creeping from 0 to 100 over two minutes tells the user how far along the work is but nothing about what it is doing — and when it pauses at 40%, they cannot tell whether it is thinking or stuck.

from qgis.PyQt.QtCore import pyqtSignal

class ExportTask(QgsTask):
    stageChanged = pyqtSignal(str)

    STAGES = ["Reading source", "Reprojecting", "Buffering", "Writing output"]

    def run(self):
        for index, stage in enumerate(self.STAGES):
            if self.isCanceled():
                return False
            self.stageChanged.emit(f"{stage} ({index + 1} of {len(self.STAGES)})")
            self.setProgress(100 * index / len(self.STAGES))
            self.do_stage(index)
        return True

Breakdown: A custom signal carrying a string is the simplest way to say what is happening; connected to a label or the message bar item's text it costs nothing and removes most "is it stuck?" questions. Emitting from run() on a worker thread is safe because Qt queues the signal to the receiving thread's event loop — the slot runs on the main thread, where touching a widget is legal. Counting stages in the text ("3 of 4") gives the user a sense of scale that a percentage alone does not, particularly when the stages differ wildly in duration.

Resist the temptation to estimate a remaining time from elapsed progress. Geospatial work is rarely uniform — a buffer over dense urban geometry takes many times longer per feature than one over farmland — so a naive extrapolation is usually wrong in the direction that annoys people most. A stage name and an honest percentage age better than a confident and incorrect countdown.

A stage name explains a bar that has stopped movingThe upper display is a bare progress bar sitting at forty percent with no indication of what is happening. The lower display shows the same bar with the current stage named and numbered, so the pause is legible as a long writing step rather than as a hang.Same progress, one of them answers the obvious questionbar only40%stuck, or slow? the user cannot tellwith stages40%Writing output (4 of 4) — the slow step, and it is runningA stage name ages better than an estimated time that assumes uniform work

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.22 LTR3.9All signals present; Qgis.MessageLevel members named identically.
3.28 LTR3.9Identical behaviour.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Qt enum members should be written scoped (Qt.AlignmentFlag.AlignLeft) for forward compatibility with Qt 6 builds.

Troubleshooting

  • The bar never moves. setProgress() is not being called, or it is called with a value derived from featureCount() returning -1. Guard the divisor.
  • The dialog stays in the running state after a cancel. Only taskCompleted was connected. Wire taskTerminated to the same handler.
  • "wrapped C/C++ object has been deleted". A slot touched a widget after its dialog closed. Guard with sip.isdeleted() or cancel on close.
  • The message bar fills with old progress items. popWidget() is only called on the success path.
  • Cancel takes many seconds. The loop checks the flag too rarely, or one iteration is itself long. Chain a feedback object into any inner algorithm call.
  • The progress bar jumps backwards. Two tasks are connected to the same bar. Give each run its own widget, or refuse to start while one is running.

Conclusion

Progress and cancellation are four connections and one discipline: progressChanged to a bar, both taskCompleted and taskTerminated to a single cleanup handler, isCanceled() checked every iteration, and every widget touch guarded against a dialog that may already be gone. Prefer the message bar for anything long enough that the user will go and do something else.

Frequently Asked Questions

Do I need any of this if the task manager already shows progress? For a short job, no — the panel is free and includes a cancel button. For anything the user is waiting on, put the feedback where they are already looking.

How do I show progress for work with no countable steps? Set the progress bar's range to 0–0, which renders an indeterminate animation, and leave setProgress() alone. Prefer a real count wherever one exists.

Can I cancel a Processing algorithm running inside my task? Yes — pass a QgsProcessingFeedback whose isCanceled() reflects the task's, and the algorithm will stop at its next check.

Should the cancel button ask for confirmation? No. Cancel should be immediate; a confirmation dialog on an operation the user has already decided to abandon is friction, and partial results should simply be discarded.

How do I report which stage a long job is in? Update the message bar item's text, or emit a custom signal carrying a stage name and connect it to a label. Both are delivered on the main thread when emitted from finished() or via a queued connection.