QgsTask vs threading.Thread in PyQGIS
When to use QgsTask instead of a raw threading.Thread in PyQGIS — Qt event-loop integration, progress and cancellation, thread-safety of QGIS objects, and…
TL;DR: For anything running inside QGIS, prefer QgsTask. It plugs into the Qt event loop and the QgsTaskManager, gives you progress reporting, cooperative cancellation, and a finished() callback that always runs on the main thread — so writing results back to the project is safe by construction. A raw threading.Thread has no lifecycle integration and makes it far too easy to touch QGIS objects from the wrong thread. Neither one escapes the Global Interpreter Lock, so pure-Python CPU-bound work stays single-core with either choice.
This page is part of the Asynchronous Task Execution with QgsTask guide, itself part of the broader Plugin Development & UI Integration in QGIS reference. Where the parent guide teaches you how to build a QgsTask, this page answers a narrower question: given that Python already ships threading.Thread, why bother with QgsTask at all — and when is a raw thread actually the right call?
The Decision at a Glance
Both QgsTask and threading.Thread move work off the calling thread. The difference is everything that happens around that work: how it is scheduled, how you observe its progress, how you stop it, and how you get results back into the UI without corrupting the Qt object tree. QgsTask supplies all of that as a managed lifecycle; threading.Thread supplies none of it and leaves you to reinvent — usually incorrectly — the marshalling that the Qt event loop already does for free.
The matrix below summarises the trade-offs. Read it as “what does each option give you out of the box,” not “which is technically capable” — with enough manual signal plumbing a raw thread can imitate most of QgsTask, but that plumbing is exactly the surface where thread-safety bugs breed.
Two rows deserve emphasis because they cut against intuition. First, QGIS-object safety is a tie — a QgsTask does not magically let you touch a layer from run(); both approaches demand that you pre-fetch data on the main thread. Second, the GIL rows are also ties: QgsTask is not a parallelism tool, it is a responsiveness tool. If your bottleneck is a tight pure-Python loop, neither option will use a second core.
The rows where QgsTask clearly wins are the lifecycle rows: event-loop integration, progress and cancellation, the main-thread callback, and the resulting boilerplate. Those are not conveniences — they are the load-bearing machinery of a correct background worker inside a Qt application. A raw thread forces you to rebuild each of them by hand, and every hand-built replacement is a fresh opportunity to violate Qt’s thread affinity. That asymmetry is why the default should be QgsTask and the burden of proof sits with anyone reaching for threading.Thread.
The Same Work, Two Ways
The clearest way to see the difference is to write the identical job twice — sum the areas of a list of pre-fetched geometries and report the total back to the UI. Watch what each version has to do to deliver its result to the main thread safely.
"""
qgstask_vs_thread.py
Two implementations of the same background job:
buffer a list of pre-fetched geometries and hand the results
back to the main thread. Compatible with QGIS 3.28+ LTR.
Both take geometries that were already cloned on the MAIN thread —
never fetch them from a layer inside the worker.
"""
from __future__ import annotations
from typing import Optional
from qgis.core import (
Qgis,
QgsApplication,
QgsGeometry,
QgsMessageLog,
QgsTask,
)
from qgis.PyQt.QtCore import QObject, pyqtSignal
# --- Option A: QgsTask -------------------------------------------------
class BufferTask(QgsTask):
"""Buffer geometries in a worker thread, report via finished().
finished() is invoked by the QgsTaskManager ON THE MAIN THREAD,
so touching the UI or the project from there is safe by construction.
"""
def __init__(self, geometries: list[QgsGeometry], distance: float) -> None:
super().__init__("Buffer geometries", QgsTask.CanCancel)
# Own private clones so a concurrent layer edit cannot corrupt us.
self.geometries: list[QgsGeometry] = [g.clone() for g in geometries]
self.distance = distance
self.results: list[QgsGeometry] = []
def run(self) -> bool:
"""Runs on a worker thread. No QgsProject, iface, or widget access."""
total = len(self.geometries)
for i, geom in enumerate(self.geometries):
if self.isCanceled(): # cooperative cancellation, built in
return False
self.results.append(geom.buffer(self.distance, 8))
self.setProgress((i + 1) / total * 100) # progress, built in
return True
def finished(self, result: bool) -> None:
"""Runs on the MAIN thread — safe to write back to the project here."""
if result:
QgsMessageLog.logMessage(
f"Buffered {len(self.results)} geometries.",
"BufferTask", level=Qgis.Success,
)
# e.g. QgsProject.instance().addMapLayer(build_layer(self.results))
def run_with_qgstask(geometries: list[QgsGeometry], distance: float) -> None:
"""Submit the task; the manager owns scheduling and the finished() call."""
task = BufferTask(geometries, distance)
QgsApplication.taskManager().addTask(task)
# --- Option B: threading.Thread ---------------------------------------
import threading
class ThreadBufferWorker(QObject):
"""Raw-thread equivalent — every piece of lifecycle is hand-built.
A plain thread CANNOT safely call the UI, so we MUST route results
through a queued Qt signal to hop back onto the main thread ourselves.
"""
# This signal is the manual replacement for QgsTask.finished().
completed = pyqtSignal(bool, list)
def __init__(self, geometries: list[QgsGeometry], distance: float) -> None:
super().__init__()
self.geometries = [g.clone() for g in geometries]
self.distance = distance
self._cancel = threading.Event() # cancellation: hand-rolled
def cancel(self) -> None:
self._cancel.set()
def _work(self) -> None:
"""Runs on the worker thread. Emitting a signal is the ONLY safe
way to reach the main thread — never touch the UI directly here."""
results: list[QgsGeometry] = []
for geom in self.geometries:
if self._cancel.is_set():
self.completed.emit(False, []) # marshalled to main thread
return
results.append(geom.buffer(self.distance, 8))
# No setProgress() — you would emit another signal yourself here.
self.completed.emit(True, results)
def start(self) -> None:
threading.Thread(target=self._work, daemon=True).start()
def run_with_thread(geometries: list[QgsGeometry], distance: float) -> None:
"""Connect the completion signal, THEN start — you own every wire."""
worker = ThreadBufferWorker(geometries, distance)
def on_completed(ok: bool, results: list) -> None:
# This slot runs on the main thread only because `completed` is a
# Qt signal delivered through the event loop via a queued connection.
if ok:
QgsMessageLog.logMessage(
f"Buffered {len(results)} geometries.",
"ThreadWorker", level=Qgis.Success,
)
worker.completed.connect(on_completed) # queued: worker -> main thread
worker.start()
# NOTE: keep a reference to `worker` alive, or it is garbage-collected
# mid-flight and the signal never arrives.
The QgsTask version’s result path is a single method, finished(), and the framework guarantees it fires on the main thread. The threading.Thread version has to introduce a QObject, declare a pyqtSignal, connect it before starting, and keep the worker object alive — all of it manual reconstruction of what QgsTask provides for free. Every one of those manual steps is a place to get thread affinity wrong.
Architecture Breakdown
Qt event-loop integration and QgsTaskManager
A QgsTask is not just a thread wrapper — it is a citizen of the QGIS runtime. When you call QgsApplication.taskManager().addTask(task), the manager places the task in a queue, allocates it a thread from Qt’s pool, tracks its state through a Running → Complete / Terminated machine, and surfaces it in the QGIS Tasks panel where a user can watch or abort it. Because the manager lives on the Qt event loop, the transition from worker back to finished() is a queued event delivered on the main thread. That is the mechanism that makes finished() safe.
A threading.Thread sits entirely outside this system. Nothing schedules it against other QGIS work, nothing shows it in the UI, and nothing marshals its completion onto the main thread — the operating system simply runs your target callable. To bridge back into QGIS you must lean on Qt signals yourself, which is precisely the QueuedConnection dispatch covered in Signal and Slot Event Handling in QGIS. In other words, to make a raw thread safe you end up rebuilding a worse version of the event-loop integration QgsTask already ships.
Thread-safety of QgsProject and QgsMapLayer
This is the rule that catches everyone, and it applies identically to both options: never touch QgsProject, a QgsMapLayer, iface, or any Qt widget from a worker thread. These objects have Qt thread affinity to the main thread and are not internally synchronised. Reading a QgsVectorLayer’s features from a background thread can crash the interpreter, corrupt the layer’s spatial index, or return partial data with no error at all.
The correct pattern is pre-fetch-and-clone, and it does not change between QgsTask and threading.Thread:
from qgis.core import QgsProject, QgsGeometry
def prefetch_geometries(layer_id: str) -> list[QgsGeometry]:
"""Read + clone on the MAIN thread before any worker starts.
Returns detached QgsGeometry copies that carry no reference back
into the layer, so the worker can operate on them safely.
"""
layer = QgsProject.instance().mapLayer(layer_id)
if layer is None or not layer.isValid():
return []
# clone() detaches each geometry from the layer's feature store.
return [f.geometry().clone() for f in layer.getFeatures()]
Because this constraint is a tie, it is never a reason to choose a raw thread — but it is a reason to prefer QgsTask, whose run()/finished() split makes the boundary explicit and hard to cross by accident. With a bare thread the boundary is invisible, so the temptation to “just call iface quickly” from the worker is far stronger.
The GIL and when threads still help
CPython’s Global Interpreter Lock allows only one thread to execute Python bytecode at a time. Neither QgsTask nor threading.Thread changes this. If your workload is a pure-Python loop — iterating features, doing arithmetic in the interpreter, building lists — moving it to a background worker keeps the UI responsive but does not make it finish faster, and adding more worker threads will not use more cores.
Threads do deliver real concurrency in two cases, and both apply equally to QgsTask and threading.Thread:
- I/O-bound work. Reading files, querying a PostGIS database, or fetching a WFS layer releases the GIL while the thread waits on the operating system, so other Python threads run during the wait.
- GIL-releasing C extensions. Heavy numeric and geometry libraries drop the GIL inside their C inner loops. GDAL/OGR raster and vector I/O, NumPy array maths, and much of Shapely and GEOS release the lock while crunching, so a worker running those can genuinely overlap with the main thread and even with other workers.
The practical takeaway: if you need the UI to stay responsive, use QgsTask regardless of workload. If you additionally need pure-Python CPU work to go faster, threads of either kind will not help — reach for multiprocessing, a ProcessPoolExecutor, or push the hot path into a GIL-releasing library. For a worked example of keeping the interface alive during a genuinely heavy pipeline, see Running Heavy Geoprocessing in Background Without Freezing the UI.
When a raw threading.Thread is defensible
QgsTask is the default, but it is not universal. A plain threading.Thread is reasonable when:
- You are writing a standalone script with no Qt event loop running — there is no
QgsTaskManagerto schedule against and no UI to protect, so the ceremony ofQgsTaskbuys you little. - You need to wrap a single blocking third-party call (a network client, a subprocess) whose result you will collect synchronously, and you are not writing anything back into a live QGIS project.
- You are inside a framework that already owns its own executor — for instance code driven by
concurrent.futures— and adding QGIS’s task manager on top would mean two schedulers fighting.
Even then, the moment results must land in a live QgsProject or on the canvas, route them through a queued Qt signal — never write from the worker.
Production Best Practices
- Default to
QgsTaskfor anything inside QGIS. Only drop tothreading.Threadfor standalone scripts or single blocking calls with no UI to update. - Pre-fetch and clone on the main thread for both options. Pass detached
QgsGeometrycopies and plain Python data into the worker; never pass aQgsVectorLayeror readQgsProjectfromrun(). - Write results back only on the main thread — in
QgsTask.finished(), or through aQueuedConnectionsignal from a raw thread. - Do not expect parallel speed-up for pure-Python CPU work. If a tight interpreter loop is the bottleneck, use
multiprocessingor a GIL-releasing C extension (GDAL, NumPy, Shapely). - Use threads freely for I/O and GIL-releasing libraries — that is where background workers genuinely overlap with the main thread.
- Keep a reference to any raw worker object alive until its completion signal fires; a garbage-collected worker silently drops its result.
- Prefer cooperative cancellation.
QgsTask.isCanceled()is built in; for a raw thread, poll athreading.Event— never force-kill a thread.
Related
- Asynchronous Task Execution with QgsTask — parent guide covering the full
QgsTasksubclassing, pre-fetch, cancellation, and task-chaining workflow - Running Heavy Geoprocessing in Background Without Freezing the UI — chunking and memory strategies for keeping the interface alive under load
- Signal and Slot Event Handling in QGIS — how
Qt.QueuedConnectionmarshals a worker’s result back onto the main thread