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.
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.
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:
- No QGIS project or UI access.
QgsProject.instance(),iface, anyQgsMapCanvasmethod, and anyQWidgetsubclass are off-limits. Calling them from a worker thread causesQObject::moveToThreadassertions or silent segmentation faults. - 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. - 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
pyqtSignalinstances 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.
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 intorun()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.Successfor completed runs,Qgis.Warningfor partial failures or skipped features, andQgis.Criticalonly for states that corrupt data or invalidate downstream results.
Related
- Asynchronous Task Execution with QgsTask — parent guide covering the full threading model, prerequisites, and advanced patterns
- Signal and Slot Event Handling in QGIS — how Qt queues cross-thread signal delivery and how to connect slots safely
- Vector and Raster Data Access Patterns — safe feature iteration strategies to use when pre-fetching data on the main thread
- Properly Cleaning Up Plugin Resources on QGIS Shutdown — how to stop running tasks and disconnect signals in
unload()