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.
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.
ifaceavailable 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.
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.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | All signals present; Qgis.MessageLevel members named identically. |
| 3.28 LTR | 3.9 | Identical behaviour. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Qt 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 fromfeatureCount()returning-1. Guard the divisor. - The dialog stays in the running state after a cancel. Only
taskCompletedwas connected. WiretaskTerminatedto 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.