Running Heavy Geoprocessing in Background Without Freezing UI

Running heavy geoprocessing in the background without freezing the UI requires offloading CPU-intensive operations to a worker thread using QGIS's built-in…

Running heavy geoprocessing in the background without freezing the UI requires offloading CPU-intensive operations to a worker thread using QGIS’s built-in QgsTask framework. By subclassing QgsTask and implementing the run() method, you execute geoprocessing logic outside the main GUI thread while maintaining thread-safe communication through signals and QgsTaskManager. This prevents the QGIS interface from locking up during raster calculations, large vector overlays, network analysis, or batch exports.

Why the Main Thread Freezes & How QgsTask Solves It

QGIS relies on Qt’s single-threaded event loop to handle UI rendering, map canvas updates, and user input. When a Python script executes a long-running operation synchronously, it monopolizes this loop, blocking event processing and triggering the “Application Not Responding” state. The Asynchronous Task Execution with QgsTask pattern delegates work to a managed thread pool. QGIS automatically handles thread lifecycle, cancellation signals, and progress reporting. When background work completes, the finished() callback executes safely on the main thread, allowing you to update layers, trigger map refreshes, or show dialogs without violating Qt’s thread-affinity rules. For broader architectural guidance on structuring plugins and custom tools, consult the Plugin Development & UI Integration documentation.

flowchart LR
    M["Main thread: pre-fetch data, addTask()"] --> W["Worker: run() heavy work on pre-fetched data"]
    W -->|"setProgress / isCanceled"| W
    W --> F["Main thread: finished(result)"]
    F --> UI["Update layers, refresh canvas, dialogs"]

Production-Ready Implementation

The following implementation processes a list of geometries pre-fetched on the main thread. All heavy computation happens in run(), and only the finished() callback touches the QGIS project or UI. A custom signal carries results from the worker to any listening UI component.

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

class HeavyGeoprocessingTask(QgsTask):
    # Custom signal to safely pass results back to the main thread
    processing_complete = pyqtSignal(list, bool)

    def __init__(self, geometries: list, output_path: str, buffer_dist: float = 10.0,
                 description="Heavy Geoprocessing"):
        super().__init__(description, QgsTask.CanCancel)
        # Clone geometries so this task owns its data independently
        self.geometries = [g.clone() for g in geometries]
        self.output_path = output_path
        self.buffer_dist = buffer_dist
        self.result_geometries = []
        self.result_message = ""

    def run(self) -> bool:
        """
        Executes in a background thread.
        MUST remain strictly thread-safe: no QgsProject, iface, or UI calls.
        """
        try:
            total = len(self.geometries)
            if total == 0:
                self.result_message = "Input list contains no geometries."
                return False

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

                # Replace with actual heavy logic (e.g., geometry processing, GDAL calls)
                buffered = geom.buffer(self.buffer_dist, 8)
                self.result_geometries.append(buffered)

                # Thread-safe progress reporting (0–100)
                self.setProgress((i + 1) / total * 100)

            self.result_message = f"Successfully processed {len(self.result_geometries)} geometries."
            return True
            
        except Exception as e:
            self.result_message = f"Error: {str(e)}"
            return False

    def finished(self, result: bool):
        """Executes on the main thread. Safe for UI updates and layer management."""
        if result:
            QgsMessageLog.logMessage(self.result_message, "Geoprocessing", Qgis.Success)
        else:
            QgsMessageLog.logMessage(self.result_message, "Geoprocessing", Qgis.Warning)

        # Emit custom signal for UI components listening to this task
        self.processing_complete.emit(self.result_geometries, result)

Thread-Safety Rules & Best Practices

Violating Qt’s thread model is the primary cause of crashes and silent data corruption in QGIS plugins. Adhere to these boundaries:

Inside run() (Worker Thread):

  • ✅ Use self.setProgress(), self.isCanceled(), and QgsMessageLog.logMessage()
  • ✅ Operate on pre-fetched data (geometries, attribute dicts, plain Python objects)
  • ✅ Run pure geometry operations on QgsGeometry objects cloned before task submission
  • ❌ Never call QgsProject.instance(), iface, QgsMapCanvas, or modify any QObject not created in this thread
  • ❌ Never call QgsVectorLayer.getFeatures() — data providers are not thread-safe

Inside finished() (Main Thread):

  • ✅ Update UI elements, add layers to the project, refresh the canvas, or emit signals
  • ✅ Parse results and trigger downstream workflows
  • ❌ Do not perform heavy computation here; it defeats the purpose of background execution

Task Execution & UI Integration

Pre-fetch all layer data on the main thread, then instantiate the task and hand it to QGIS’s global task manager.

python
from qgis.core import QgsApplication, QgsProject

# --- Main thread: fetch data before submitting ---
layer = iface.activeLayer()
if not layer:
    iface.messageBar().pushWarning("No Layer", "Select a vector layer first.")
else:
    # Fetch and clone geometries on the main thread
    geometries = [f.geometry() for f in layer.getFeatures()]

    task = HeavyGeoprocessingTask(
        geometries, "/tmp/processed_output.gpkg", buffer_dist=50.0, description="Batch Export"
    )
    
    # Optional: Connect to custom signal for UI feedback
    task.processing_complete.connect(lambda geoms, success:
        iface.messageBar().pushSuccess("Done", f"{len(geoms)} features saved")
        if success else iface.messageBar().pushWarning("Failed", "Processing failed"))
    
    QgsApplication.taskManager().addTask(task)

The task manager respects system resources and prevents thread starvation. For detailed API behavior, refer to the official QgsTask Class Reference.

Advanced Patterns: Cancellation, Dependencies & GDAL

  • Graceful Cancellation: Always check self.isCanceled() inside loops. GDAL and OGR operations run at the C level and cannot be interrupted mid-call. Structure your pipeline to check cancellation between discrete processing steps.
  • Task Dependencies: Use QgsTask.addSubTask() to chain tasks. The manager supports dependency graphs, ensuring Task B only runs after Task A succeeds.
  • Quick Scripts: For lightweight operations, skip subclassing and use QgsTask.fromFunction(). It wraps a callable in a task automatically, though it lacks fine-grained progress control.
  • Memory Management: Large datasets should be processed in chunks to avoid exhausting RAM. QGIS’s thread pool does not isolate memory allocation; heavy allocations still impact the host process.

Thread safety in Qt follows strict affinity rules: objects created in one thread cannot be directly accessed from another. Review Qt Threading Basics to understand signal-slot queueing and cross-thread communication patterns. When combined with QgsTask, these patterns enable responsive, enterprise-grade geoprocessing tools.