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.

The lifecycle of a QgsTaskA task is constructed on the main thread with a snapshot of its inputs, then handed to the task manager which queues it. The manager calls run on a worker thread, where progress is reported and cancellation is checked. When run returns, the manager calls finished back on the main thread with the boolean result, and that is where the user interface may be updated.Two of these boxes are on the main thread and one is notmain thread__init__ · take thesnapshot, keep a refaddTask() — queuedfinished(result)safe to touch the UIworker threadrun() — the actual worksetProgress() · isCanceled() · return True or Falseno widgets hereno project layers hereAnything computed in run() reaches the interface only through the object's own attributes, read in finished()

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.

Three ways to return a result, only two of them safeWriting the output to disk in the worker and loading it on the main thread is safe and works for any size. Storing plain Python data such as numbers or lists on the task and reading it in finished is safe for small results. Constructing a layer in the worker and adding it to the project from there is unsafe and may crash.Send data across the boundary, not objectswrite, then loadrun() writes a GeoPackagefinished() loads the pathany size, fully safethe default choiceplain data on selfnumbers, lists, dictsread in finished()safe while it stays smallgood for summariesa layer, from run()addMapLayer() in the workeror a project layer capturedcrashes, eventuallynever do this

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.

The feedback object bridges the task and the algorithmsThe task owns a feedback object whose isCanceled delegates to the task and whose setProgress reports upward. Both chained algorithm calls receive that same object and the same processing context, so a cancel request reaches whichever algorithm is currently running and intermediate layers stay alive across the calls.Cancellation only reaches an algorithm that was given the feedbackPipelineTaskisCanceled · setProgressTaskFeedbackdelegates both waysnative:reprojectlayernative:bufferOne shared QgsProcessingContext keeps intermediate layers alive across the chainScale progress per stage so a two-step bar does not run 0–100 twice

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.22 LTR3.9QgsTask, materialize() and QgsProcessingAlgRunnerTask all present and behave as shown.
3.28 LTR3.9Identical API.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12QgsTask.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 self or a module-level variable until finished() fires.
  • QGIS crashes partway through. Something in run() touched a project layer, a widget or iface. Move it to finished() or snapshot it before starting.
  • Cancel does nothing. isCanceled() is not checked inside the loop, or the task was created without QgsTask.CanCancel.
  • The progress bar jumps to 100% and sits there. setProgress() is called with a value derived from a count that was wrong — often featureCount() returning -1 for a provider that does not know. Guard with max(count, 1).
  • finished() reports failure with no message. An exception escaped run(). Wrap the body in try/except and store the exception as shown.
  • Results are empty although the work succeeded. The result was stored on a local variable rather than on self, so finished() 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.