Run a Long Task with QgsTask in PyQGIS
QgsTask is QGIS's answer to a plugin that locks the window. It gives you a managed worker thread, an entry in the task manager with a progress bar and a cancel button, and a guaranteed callback back on the main thread when the work finishes. What it does not give you is protection from the two mistakes everyone makes first: touching a project layer from the worker, and letting the task object be garbage-collected before it starts.
This recipe belongs to Background Tasks and Plugin Performance. It covers the subclass, the thread-safe snapshot, returning a result to the main thread, running a Processing algorithm as a task, and chaining dependent tasks.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- A plugin skeleton — see Plugin Boilerplate and Structure.
- A piece of work that genuinely takes more than a second or two. Below that, a background task adds complexity for no perceptible gain.
Subclass QgsTask
from qgis.core import (
QgsTask, QgsApplication, QgsFeatureRequest, QgsMessageLog, Qgis,
)
class AreaSumTask(QgsTask):
"""Sum polygon areas without blocking the interface."""
def __init__(self, description, source):
super().__init__(description, QgsTask.CanCancel)
self.source = source
self.total_area = 0.0
self.exception = None
def run(self):
try:
count = self.source.featureCount() or 1
for index, feature in enumerate(self.source.getFeatures()):
if self.isCanceled():
return False
self.total_area += feature.geometry().area()
if index % 500 == 0:
self.setProgress(100 * index / count)
except Exception as exc:
self.exception = exc
return False
return True
def finished(self, result):
if self.exception is not None:
QgsMessageLog.logMessage(f"failed: {self.exception}", "MyPlugin", Qgis.Critical)
elif result:
QgsMessageLog.logMessage(f"total area {self.total_area:,.0f}", "MyPlugin", Qgis.Success)
else:
QgsMessageLog.logMessage("cancelled", "MyPlugin", Qgis.Info)
Breakdown: QgsTask.CanCancel is what puts a cancel button next to the progress entry; without it the flag can never be set. Catching exceptions inside run() and storing them is necessary because an exception escaping a worker thread does not propagate anywhere useful — it disappears, and the task simply reports failure with no explanation. finished() distinguishes three outcomes, and treating "cancelled" separately from "failed" matters to the user: one is their decision and the other is a bug. Progress is set every 500 features rather than every feature because the signal crosses a thread boundary and is not free.
Hand over something the task can own
The task must not read a layer that lives in the project. Take a snapshot on the main thread first.
from qgis.core import QgsProject, QgsFeatureRequest
layer = QgsProject.instance().mapLayersByName("parcels")[0]
request = QgsFeatureRequest().setSubsetOfAttributes([]) # geometry only
snapshot = layer.materialize(request)
task = AreaSumTask("Summing parcel areas", snapshot)
QgsApplication.taskManager().addTask(task)
self.task = task # keep a reference
Breakdown: materialize() runs on the main thread and returns an independent in-memory layer holding exactly the features the request selects — the task owns it, so no cross-thread access occurs. Requesting no attributes makes the copy dramatically cheaper when only geometry is needed, which is the same lever described in Speed Up Feature Iteration with QgsFeatureRequest. The last line is not decoration: addTask() does not keep Python's reference alive, and a task collected between construction and execution silently never runs. Assigning it to self — or any object that outlives the function — is the fix.
For a very large layer where copying is not acceptable, pass the layer's source() string and construct a fresh QgsVectorLayer inside run(). That layer belongs to the worker thread and is safe.
Return a result to the main thread
Results travel as attributes on the task, read in finished(), or as a custom signal.
from qgis.PyQt.QtCore import pyqtSignal
class BufferTask(QgsTask):
resultReady = pyqtSignal(object)
def run(self):
self.output_path = "/tmp/buffered.gpkg"
# … write the output …
return True
def finished(self, result):
if result:
self.resultReady.emit(self.output_path)
task = BufferTask("Buffering")
task.resultReady.connect(lambda path: iface.addVectorLayer(path, "Buffered", "ogr"))
QgsApplication.taskManager().addTask(task)
Breakdown: finished() already runs on the main thread, so emitting from there delivers the signal safely to any connected slot. Adding the layer to the project happens in the slot, never in run() — QgsProject is main-thread property. Emitting a path rather than a layer object is deliberate: the layer is then constructed on the thread that will own it.
Run a Processing algorithm as a task
For a single algorithm there is a purpose-built wrapper — no subclass required.
from qgis.core import QgsApplication, QgsProcessingAlgRunnerTask, QgsProcessingContext, QgsProcessingFeedback
from qgis import processing
context = QgsProcessingContext()
feedback = QgsProcessingFeedback()
algorithm = QgsApplication.processingRegistry().algorithmById("native:buffer")
def on_complete(ok, results):
if ok:
iface.addVectorLayer(results["OUTPUT"], "Buffered", "ogr")
task = QgsProcessingAlgRunnerTask(
algorithm,
{"INPUT": layer.source(), "DISTANCE": 50, "OUTPUT": "/tmp/buffered.gpkg"},
context,
feedback,
)
task.executed.connect(on_complete)
QgsApplication.taskManager().addTask(task)
Breakdown: QgsProcessingAlgRunnerTask handles the threading, progress and cancellation for any registered algorithm, so a plugin that mostly orchestrates Processing needs no custom task class at all. Passing layer.source() rather than the layer object keeps the input thread-safe. The executed signal fires on the main thread with a success flag and the results dictionary — the same dictionary processing.run() would have returned, as described in Run a Processing Algorithm from a Script. The context must outlive the task, so keep a reference to it as well.
Run Processing inside your own task
A task that wraps several algorithms — rather than one — needs its own feedback object so progress and cancellation reach them.
from qgis.core import QgsProcessingFeedback, QgsProcessingContext
import processing
class TaskFeedback(QgsProcessingFeedback):
def __init__(self, task):
super().__init__(False)
self._task = task
def isCanceled(self):
return self._task.isCanceled()
def setProgress(self, progress):
self._task.setProgress(progress)
class PipelineTask(QgsTask):
def run(self):
feedback = TaskFeedback(self)
context = QgsProcessingContext()
reprojected = processing.run("native:reprojectlayer", {
"INPUT": self.source_uri, "TARGET_CRS": "EPSG:27700",
"OUTPUT": "TEMPORARY_OUTPUT",
}, context=context, feedback=feedback)["OUTPUT"]
if self.isCanceled():
return False
self.output_path = processing.run("native:buffer", {
"INPUT": reprojected, "DISTANCE": 25,
"OUTPUT": self.destination,
}, context=context, feedback=feedback)["OUTPUT"]
return True
Breakdown: Overriding isCanceled() to delegate to the task is what makes the cancel button stop an algorithm mid-run — without it, pressing cancel sets a flag the algorithm never consults, and the user waits out a step they asked to abandon. Delegating setProgress gives the task's progress bar the current algorithm's progress, which is honest for one step and misleading for several; scaling it per stage (self.setProgress(50 + progress / 2) for the second of two) is the small refinement that makes a multi-stage bar mean something.
Passing the same QgsProcessingContext through every call lets layers created by one algorithm be found by the next, and — importantly for a task — keeps them alive for the duration. A context created inside the loop would let intermediate layers be collected while a later step still refers to them. Checking isCanceled() between the algorithms costs nothing and stops the chain at the first opportunity.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | QgsTask, materialize() and QgsProcessingAlgRunnerTask all present and behave as shown. |
| 3.28 LTR | 3.9 | Identical API. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | QgsTask.Flag enum members are scoped (QgsTask.Flag.CanCancel); the unscoped spelling still works. |
Troubleshooting
- The task never runs. No Python reference was kept. Assign it to
selfor a module-level variable untilfinished()fires. - QGIS crashes partway through. Something in
run()touched a project layer, a widget oriface. Move it tofinished()or snapshot it before starting. - Cancel does nothing.
isCanceled()is not checked inside the loop, or the task was created withoutQgsTask.CanCancel. - The progress bar jumps to 100% and sits there.
setProgress()is called with a value derived from a count that was wrong — oftenfeatureCount()returning-1for a provider that does not know. Guard withmax(count, 1). finished()reports failure with no message. An exception escapedrun(). Wrap the body intry/exceptand store the exception as shown.- Results are empty although the work succeeded. The result was stored on a local variable rather than on
self, sofinished()never saw it.
Conclusion
A correct QgsTask is four things: a subclass whose run() touches nothing owned by the main thread, a snapshot handed over before it starts, a reference kept until it finishes, and a finished() that applies the result. When the work is a single Processing algorithm, QgsProcessingAlgRunnerTask provides all of it without a subclass.
Frequently Asked Questions
How do I make one task wait for another?
Call task.addSubTask(child, [dependencies]), or add the dependency list when adding to the manager. The manager will not start a task until its dependencies have completed successfully.
Can I update a progress bar in my own dialog?
Yes — connect to the task's progressChanged signal, which is delivered on the main thread. That pattern is covered in Show Progress and Support Cancellation in a QGIS Plugin.
Is materialize() expensive?
It copies features into memory, so it costs roughly what one full read costs. Narrow the request first; for very large layers, rebuild the layer from its source inside run() instead.
What happens if the user closes the plugin dialog mid-task?
The task keeps running — it belongs to the manager, not the dialog. Guard finished() against a deleted widget, or cancel the task from the dialog's close handler.
Can I write to a database from a task?
Yes, provided the connection is created inside run(). Do not share a layer or connection object created on the main thread — see Append Features to a PostGIS Table in PyQGIS.