Running Heavy Geoprocessing in Background Without Freezing the UI

Offload CPU-intensive QGIS geoprocessing to a background thread using QgsTask. Complete runnable code with thread-safety rules, progress reporting, and…

Running heavy geoprocessing in the background without freezing the UI requires subclassing QgsTask, implementing the run() method with your CPU-intensive logic, and submitting the task to QgsApplication.taskManager() — which executes it on a managed worker thread while keeping Qt’s event loop free for UI rendering. This is part of the Asynchronous Task Execution with QgsTask guide, which covers the full threading model. For broader architectural context, see Plugin Development & UI Integration.

Complete Runnable Implementation

The class below processes a list of geometries pre-fetched on the main thread. All heavy computation happens inside run(); only finished() touches the project or UI. A custom pyqtSignal carries results back to any connected widget without violating Qt’s thread-affinity rules.

python
from __future__ import annotations

from qgis.core import (
    QgsTask,
    QgsMessageLog,
    QgsGeometry,
    Qgis,
    QgsApplication,
)
from qgis.PyQt.QtCore import pyqtSignal


class HeavyGeoprocessingTask(QgsTask):
    """
    Background geoprocessing task using QgsTask.

    Pre-fetch all layer data on the main thread, pass it into the
    constructor, then submit via QgsApplication.taskManager().addTask().

    Signals:
        processing_complete(list[QgsGeometry], bool): emitted in finished()
            with the processed geometry list and a success flag.
    """

    processing_complete = pyqtSignal(list, bool)

    def __init__(
        self,
        geometries: list[QgsGeometry],
        buffer_dist: float = 10.0,
        description: str = "Heavy Geoprocessing",
    ) -> None:
        super().__init__(description, QgsTask.CanCancel)
        # Clone each geometry so this task owns its data independently of
        # the source layer. See /plugin-development-ui-integration/
        # asynchronous-task-execution-with-qgstask/ for ownership rules.
        self.geometries: list[QgsGeometry] = [g.clone() for g in geometries]
        self.buffer_dist = buffer_dist
        self.result_geometries: list[QgsGeometry] = []
        self.result_message: str = ""

    def run(self) -> bool:
        """
        Executes on a background thread managed by QgsTaskManager.

        Contract:
          - MUST be thread-safe: no QgsProject, iface, or QWidget access.
          - MUST check self.isCanceled() at each loop iteration.
          - MUST call self.setProgress(0-100) to feed the task manager bar.
          - Return True on success, False on failure or cancellation.
        """
        total = len(self.geometries)
        if total == 0:
            self.result_message = "Input list is empty — nothing to process."
            return False

        try:
            for i, geom in enumerate(self.geometries):
                if self.isCanceled():
                    self.result_message = "Cancelled by user."
                    return False

                # ----- Replace with your heavy logic -----
                # Safe: pure geometry operations on cloned QgsGeometry objects.
                buffered = geom.buffer(self.buffer_dist, segments=8)
                if buffered.isEmpty():
                    QgsMessageLog.logMessage(
                        f"Geometry {i} produced empty buffer — skipped.",
                        "HeavyGeoprocessingTask",
                        Qgis.Warning,
                    )
                    continue
                self.result_geometries.append(buffered)
                # -----------------------------------------

                # Thread-safe progress update (drives the built-in progress bar)
                self.setProgress((i + 1) / total * 100)

            self.result_message = (
                f"Processed {len(self.result_geometries)} of {total} geometries."
            )
            return True

        except Exception as exc:  # noqa: BLE001
            self.result_message = f"Unexpected error: {exc}"
            return False

    def finished(self, result: bool) -> None:
        """
        Executes on the MAIN thread — safe for all UI and project operations.

        Never perform heavy computation here; the event loop is blocked for
        the duration of this method, just as it would be in any slot.
        """
        level = Qgis.Success if result else Qgis.Warning
        QgsMessageLog.logMessage(
            self.result_message, "HeavyGeoprocessingTask", level
        )
        # Emit the custom signal so connected UI components receive results
        # without needing a direct reference to this task object.
        self.processing_complete.emit(self.result_geometries, result)

Thread Execution Flow

The diagram below traces how data and control move across the two threads during a single task lifecycle. The critical constraint — that no UI or project call may cross into the worker thread — is shown as the hard boundary in the middle.

QgsTask Thread Execution Flow Diagram showing how data flows from the main thread through the QgsTaskManager worker thread and back to the main thread via the finished() callback and custom signals. MAIN THREAD WORKER THREAD (QgsTaskManager pool) MAIN THREAD (finished callback) Pre-fetch data layer.getFeatures() clone geometries addTask(task) QgsApplication .taskManager() run() heavy logic, isCanceled(), setProgress() → True/False progress bar updates finished(result) emit signal, log message UI update add layers, refresh canvas, message bar ✗ QgsProject ✗ iface / widgets ✗ getFeatures() Forbidden in run()

Architecture Breakdown

__init__ — Data Ownership and Task Flags

Pass the QgsTask.CanCancel flag to make the task appear in QGIS’s task manager progress widget with a cancel button. Inside the constructor, clone every QgsGeometry object you receive. A QgsGeometry references a shared C++ geometry object; if the source feature goes out of scope or the layer is reloaded while the task runs, you will access freed memory. Calling .clone() produces an independent deep copy that the task owns exclusively.

Never store a reference to the source QgsVectorLayer inside the task. Data providers are not thread-safe, and layer objects are owned by QgsProject, which lives on the main thread.

run() — The Worker Contract

run() executes on a thread from QGIS’s global thread pool. Three invariants govern everything inside it:

  1. No QGIS project or UI access. QgsProject.instance(), iface, any QgsMapCanvas method, and any QWidget subclass are off-limits. Calling them from a worker thread causes QObject::moveToThread assertions or silent segmentation faults.
  2. Check self.isCanceled() in every loop iteration. QGIS cannot interrupt a running C-level call (GDAL, OGR, PROJ). Structure your algorithm around discrete Python-level steps so each iteration can observe the cancellation flag before committing to the next unit of work.
  3. Report progress via self.setProgress(value). The argument is a float from 0 to 100. The task manager routes this to the status bar progress indicator without any main-thread involvement.

Return True to signal success or False to signal failure or user cancellation. The return value is passed directly to finished().

finished() — Safe Main-Thread Callback

QGIS guarantees that finished() runs on the main thread immediately after run() returns. This is the correct — and only — place to:

  • Emit pyqtSignal instances carrying computed results to connected UI slots.
  • Call QgsMessageLog.logMessage() to record outcomes.
  • Add result layers to QgsProject.instance().
  • Call iface.mapCanvas().refresh() to redraw the map.

Do not run heavy computation here. The Qt event loop is blocked for the duration of this callback, which means any work you do here degrades UI responsiveness exactly as much as synchronous main-thread code would.

Wiring the Task Into a Plugin

Pre-fetch all required data on the main thread, construct the task, connect your UI slots to its signal, then submit it. The task manager handles thread assignment, prioritization, and lifecycle management automatically.

python
from __future__ import annotations

from qgis.core import QgsApplication, QgsProject
from qgis.gui import QgisInterface


def launch_background_geoprocessing(iface: QgisInterface) -> None:
    """
    Entry point called from a toolbar action or dialog button.
    All data access happens here, on the main thread.
    """
    layer = iface.activeLayer()
    if layer is None:
        iface.messageBar().pushWarning(
            "No Layer", "Select a vector layer before running this tool."
        )
        return

    # Fetch and clone geometries on the main thread — the only safe location
    # for QgsVectorLayer.getFeatures(). See:
    # /pyqgis-core-architecture-data-handling/vector-and-raster-data-access-patterns/
    geometries = [f.geometry() for f in layer.getFeatures()]

    if not geometries:
        iface.messageBar().pushInfo("Empty Layer", "No features found.")
        return

    task = HeavyGeoprocessingTask(
        geometries=geometries,
        buffer_dist=50.0,
        description=f"Buffering {len(geometries)} features from '{layer.name()}'",
    )

    # Connect the custom signal to a slot that runs safely on the main thread.
    # Qt.AutoConnection (default) routes the call through the event queue when
    # emitter and receiver live on different threads — exactly what we need.
    # See /pyqgis-core-architecture-data-handling/signal-and-slot-event-handling-in-qgis/
    def on_complete(result_geoms: list, success: bool) -> None:
        if success:
            iface.messageBar().pushSuccess(
                "Geoprocessing Complete",
                f"{len(result_geoms)} geometries processed.",
            )
        else:
            iface.messageBar().pushWarning(
                "Geoprocessing Failed",
                "Check the QGIS message log for details.",
            )

    task.processing_complete.connect(on_complete)

    # Hand the task to the global manager — it will appear in the status bar
    # with a progress indicator and optional cancel button.
    QgsApplication.taskManager().addTask(task)

For task chains — where Task B must run only after Task A succeeds — call task_b.addSubTask(task_a, [], QgsTask.ParentDependsOnSubTask). The manager resolves the dependency graph automatically and aborts downstream tasks when an upstream task fails.

Production Best Practices

  • Clone all input geometries before submission. The source layer may be reloaded or removed while the task runs. Cloned objects are independent of the layer’s data provider.
  • Keep finished() lightweight. Expensive operations here block the event loop just as synchronous main-thread code does. Move computation into run() and pass results via signals.
  • Guard cancellation at discrete steps. GDAL and OGR execute at the C level and cannot be interrupted mid-call. Place isCanceled() checks between pipeline stages, not inside tight numerical loops that make thousands of calls per second.
  • Chunk large datasets. QGIS’s thread pool does not isolate heap allocations. Processing one million features in a single task can exhaust RAM and destabilize the host process. Split large workloads across multiple tasks submitted in sequence using addSubTask().
  • Use QgsTask.fromFunction() for one-liners. When you need a quick background call with no progress reporting or fine-grained cancellation, wrap a plain Python callable: QgsTask.fromFunction("label", my_func, on_finished=my_callback). This avoids subclassing at the cost of less granular control.
  • Test thread safety with aggressive cancellation. In development, hammer the cancel button immediately after task submission. A task that crashes on cancellation has unguarded mutable state shared with the main thread.
  • Validate CRS consistency before submission. Geometry operations that mix CRS references silently return incorrect results. Apply any required coordinate transformations on the main thread before passing geometries to the task constructor.
  • Log outcomes at the correct severity. Use Qgis.Success for completed runs, Qgis.Warning for partial failures or skipped features, and Qgis.Critical only for states that corrupt data or invalidate downstream results.