Chain Dependent QgsTasks in PyQGIS

Real plugin jobs are rarely one step. A sync job downloads data, validates it, then writes it into a layer; an analysis clips inputs, runs three independent calculations, then combines the results. Running each step as its own background task keeps QGIS responsive and shows progress, but the steps have an order: validation cannot start before the download finishes, and the combine step needs all three calculations. QgsTask supports this directly with subtasks and dependencies, so the task manager runs work in the right order, in parallel where possible, and cancels the rest when one step fails.

This recipe belongs to Background Tasks & Plugin Performance and builds on running a background task with QgsTask. It chains tasks serially and in parallel, passes results between steps, protects layers that tasks depend on, and handles cancellation.

A chain of background tasksA parent task download has three parallel subtasks clip A, clip B and clip C, which run at the same time once download finishes. A final combine subtask depends on all three and runs only after they complete. If any step fails, the dependants are cancelled and the parent reports failure. The task manager handles the order; the plugin only declares it.Declare the order; the task manager runs itdownloadparentclip Aclip Bclip Ccombinedepends on allfinishedresult

Prerequisites

  • QGIS 3.34 LTR or newer, or the QGIS 4 series.
  • Familiarity with subclassing QgsTask and its run and finished methods.

Add serial subtasks

A task can own subtasks. With ParentDependsOnSubTask, the parent's own run waits until its subtasks complete; serial order between subtasks is expressed with dependencies.

from qgis.core import QgsApplication, QgsTask, QgsMessageLog, Qgis

class Step(QgsTask):
    def __init__(self, name, work, results):
        super().__init__(name, QgsTask.CanCancel)
        self.work, self.results = work, results
        self.error = None

    def run(self):
        try:
            self.results[self.description()] = self.work(self, self.results)
            return not self.isCanceled()
        except Exception as e:
            self.error = e
            return False

    def finished(self, ok):
        level = Qgis.Info if ok else Qgis.Warning
        QgsMessageLog.logMessage(f"{self.description()}: {'ok' if ok else self.error}", "Chain", level)

results = {}
download = Step("download", lambda t, r: "/tmp/input.gpkg", results)
validate = Step("validate", lambda t, r: f"checked {r['download']}", results)
load = Step("load", lambda t, r: f"loaded after {r['validate']}", results)

parent = QgsTask.fromFunction("Sync job", lambda task: results)
parent.addSubTask(download, [], QgsTask.ParentDependsOnSubTask)
parent.addSubTask(validate, [download], QgsTask.ParentDependsOnSubTask)
parent.addSubTask(load, [validate], QgsTask.ParentDependsOnSubTask)
QgsApplication.taskManager().addTask(parent)

Breakdown: addSubTask(task, dependencies, type) takes the list of tasks this one must wait for. validate depends on download, and load on validate, so they run in sequence even though the task manager could run them in parallel. ParentDependsOnSubTask makes the parent finish only after its subtasks — and fail if any of them fails. Results pass through a shared dictionary keyed by step name; each step reads what earlier steps wrote. Only add the parent to the task manager; subtasks are scheduled with it.

Run independent steps in parallel

Steps with no dependency on each other run at the same time on the task manager's thread pool. Give them the same upstream dependency and a downstream task that depends on all of them.

Serial versus parallel subtasksSerial: three steps of 4 seconds each take 12 seconds. Parallel: three independent 4-second clips run together after a 2-second download and are followed by a 2-second combine, finishing in about 8 seconds. Parallelism helps when steps are independent and the machine has free cores; steps that write to the same file must stay serial.Independent steps overlapserial12 sparallel≈ 8 sdownload → three clips at once → combine

results = {}
clip_a = Step("clip A", lambda t, r: "a.gpkg", results)
clip_b = Step("clip B", lambda t, r: "b.gpkg", results)
clip_c = Step("clip C", lambda t, r: "c.gpkg", results)
combine = Step("combine", lambda t, r: [r["clip A"], r["clip B"], r["clip C"]], results)
download = Step("download", lambda t, r: "/tmp/input.gpkg", results)

job = QgsTask.fromFunction("Analysis", lambda task: results)
job.addSubTask(download, [], QgsTask.ParentDependsOnSubTask)
for clip in (clip_a, clip_b, clip_c):
    job.addSubTask(clip, [download], QgsTask.ParentDependsOnSubTask)
job.addSubTask(combine, [clip_a, clip_b, clip_c], QgsTask.ParentDependsOnSubTask)
QgsApplication.taskManager().addTask(job)

Breakdown: The three clips depend only on the download, so the task manager starts them together once it finishes. combine lists all three as dependencies and waits for the last. How many actually run at once depends on the thread pool size, which follows the number of cores. Writing to separate outputs is what makes parallel steps safe; two tasks writing the same GeoPackage must be serialised with a dependency.

Depend on layers, not just tasks

A task that reads a layer in the background would crash if the user removed that layer mid-run. Declaring layer dependencies makes the task manager cancel the task instead.

class ExportTask(QgsTask):
    def __init__(self, layer, path):
        super().__init__(f"Export {layer.name()}", QgsTask.CanCancel)
        self.source = layer.dataProvider().dataSourceUri()
        self.path = path
        self.setDependentLayers([layer])

    def run(self):
        from qgis.core import QgsVectorLayer
        src = QgsVectorLayer(self.source, "copy", "ogr")
        n = src.featureCount()
        for i, f in enumerate(src.getFeatures()):
            if self.isCanceled():
                return False
            self.setProgress(100 * i / max(n, 1))
        return True

Breakdown: setDependentLayers tells the task manager which layers the task needs; removing any of them cancels the task. Even so, the task opens its own copy of the source rather than iterating the project layer from a worker thread, which is the safe pattern for layer access off the main thread. The cancellation check in the loop lets the task stop promptly, and progress feeds the task bar in the status bar.

Handle failure and cancellation across the chain

When a subtask fails or is cancelled, its dependants never run and the parent reports failure. The parent's finished is the single place to tell the user what happened.

Failure stops the chainIf clip B fails, combine depends on it and is cancelled without running. Clip A and clip C may already have finished. The parent task finishes with failure and its finished method reports which step failed, using the error recorded on that step. Partial outputs from clip A and clip C should be cleaned up or kept deliberately.One failed step ends the jobclip Adoneclip Bfailedcombinecancelledparentreports failure

class Job(QgsTask):
    def __init__(self, steps):
        super().__init__("Analysis job", QgsTask.CanCancel)
        self.steps = steps

    def run(self):
        return True                          # subtasks do the work

    def finished(self, ok):
        from qgis.utils import iface
        if ok:
            iface.messageBar().pushSuccess("Analysis", "All steps finished")
            return
        failed = [s.description() for s in self.steps if s.error]
        reason = ", ".join(failed) or "cancelled"
        iface.messageBar().pushWarning("Analysis", f"Stopped: {reason}")

Breakdown: The parent's run can do nothing when the subtasks carry the work; its finished runs on the main thread, so it can safely touch the interface. Collecting the error recorded on each step names the failure precisely. Cancelling the parent — from the task bar or with job.cancel() — cancels all its subtasks too, so a single cancel button covers the whole chain. Decide whether partial outputs should be deleted or kept for a retry.

Keep references alive

Tasks created in a function and not stored anywhere may be garbage collected by Python before they run, which crashes QGIS or silently drops the work.

class Plugin:
    def start_job(self):
        self.job = Job([])                   # keep a reference on the plugin
        QgsApplication.taskManager().addTask(self.job)

Breakdown: Storing the parent task on the plugin instance keeps it and its subtasks alive until they finish. Tasks created with QgsTask.fromFunction need the same care. This is the single most common cause of "my task never ran" reports.

Add the result to the map when the chain ends

Background steps must never add layers to the project themselves — the project belongs to the main thread. The parent's finished method, which runs on the main thread, is where the output of the last step becomes a map layer.

from qgis.core import QgsProject, QgsVectorLayer

class LoadingJob(Job):
    def __init__(self, steps, results):
        super().__init__(steps)
        self.results = results

    def finished(self, ok):
        super().finished(ok)
        if not ok:
            return
        for path in self.results.get("combine", []):
            layer = QgsVectorLayer(path, path.rsplit("/", 1)[-1], "ogr")
            if layer.isValid():
                QgsProject.instance().addMapLayer(layer)

Breakdown: The worker steps only write files and record their paths in the shared results. Once every step has succeeded, finished opens those files as layers and adds them to the project, safely on the main thread. Checking isValid guards against a step that reported success but wrote an empty or unreadable file. The same pattern applies to updating a dock widget, refreshing the canvas or writing project entries: do it in finished, never in run.

Wait for a chain in a standalone script

In a script run outside the QGIS window, nothing processes events while the tasks run, so the script must wait for the task manager explicitly.

from qgis.core import QgsApplication

manager = QgsApplication.taskManager()
manager.addTask(job)
while manager.countActiveTasks() > 0:
    QgsApplication.processEvents()
print("job finished with status", job.status())

Breakdown: countActiveTasks drops to zero once the parent and all its subtasks have completed, been cancelled or failed. Processing events in the loop lets finished handlers run, since they are delivered on the main thread. In headless scripts that only need the work done, calling each step's logic directly in order is often simpler than tasks at all; chaining tasks pays off inside the QGIS interface, where responsiveness matters.

QGIS version compatibility

addSubTask, the ParentDependsOnSubTask flag, setDependentLayers and the task manager's dependency handling are available on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. In QGIS 4, enum names may also appear in scoped form; the classic names remain accepted.

Troubleshooting

  • Tasks never start. The task object was garbage collected; keep a reference.
  • Steps run out of order. A dependency is missing from addSubTask.
  • QGIS crashes during a task. A project layer was read from the worker thread; open a copy from its source instead.
  • The parent reports success despite a failing step. The subtask was added without ParentDependsOnSubTask.

Conclusion

Model multi-step jobs as a parent task with subtasks, declare order with dependency lists, let independent steps run in parallel, pass results through a shared structure, declare layer dependencies, report outcomes in the parent's finished, and keep references to tasks until they complete.

Frequently Asked Questions

Can subtasks have their own subtasks? Yes; nesting works, though a flat list with dependencies is easier to follow.

How do I show overall progress? The parent's progress is derived from its subtasks in the task bar; you can also set it yourself.

Can a task depend on a task that is not its sibling? Dependencies are expressed between subtasks of the same parent, or between top-level tasks via the task manager.

Should I use Processing instead? For chains of Processing algorithms, a model or chained processing.run running in a task is often simpler.