Background Tasks and Plugin Performance
A plugin that freezes QGIS for forty seconds is, from the user's side, a plugin that has crashed. They click, nothing happens, the window greys out, the operating system offers to kill it. The work is progressing perfectly — it is simply happening on the thread that was supposed to be drawing the interface.
This guide sits inside QGIS Plugin Development and covers the difference between a plugin that blocks and one that behaves. It starts from the plumbing established in Plugin Boilerplate and Structure and Qt Designer for GIS Interfaces, and adds the part that only shows up once real data arrives: doing the work somewhere else, telling the user how it is going, letting them stop it, and making it fast enough that they rarely need to.
What you will learn
Four pages sit under this guide. Run a Long Task with QgsTask in PyQGIS is the core mechanism. Show Progress and Support Cancellation in a QGIS Plugin makes it visible and interruptible. Speed Up Feature Iteration with QgsFeatureRequest usually removes the need for a background task altogether by making the work ten times faster. Profile Slow PyQGIS Code tells you which of the three you actually need.
The rule that governs everything
Only the main thread may touch the interface, and only the main thread may safely touch layers registered in the project.
Qt is not thread-safe for widgets, and QGIS layer objects carry the same restriction: a QgsVectorLayer in the project is owned by the main thread, and reading it from another thread while the canvas renders it is a genuine crash, not a warning. A background task may hold its own layer objects created inside the task, and may pass results back through signals — nothing more.
That single rule explains the shape of every pattern on these pages. Work is prepared on the main thread, a snapshot the task owns is handed over, the task computes, and the results come back to the main thread to be applied.
The minimal task
from qgis.core import QgsTask, QgsApplication, QgsMessageLog, Qgis
class CountTask(QgsTask):
def __init__(self, source):
super().__init__("Counting features", QgsTask.CanCancel)
self.source = source
self.total = 0
def run(self):
for index, feature in enumerate(self.source.getFeatures()):
if self.isCanceled():
return False
self.total += 1
if index % 500 == 0:
self.setProgress(100 * index / max(self.source.featureCount(), 1))
return True
def finished(self, result):
if result:
QgsMessageLog.logMessage(f"counted {self.total}", "MyPlugin", Qgis.Info)
task = CountTask(layer.materialize(QgsFeatureRequest()))
QgsApplication.taskManager().addTask(task)
Breakdown: run() executes on a worker thread and may touch nothing owned by the main thread; finished() runs back on the main thread, which is where results are applied and messages shown. Returning False from run() — including after a cancellation — tells the manager the task did not complete. Checking isCanceled() inside the loop is what makes the cancel button work: without it the flag is set and ignored. layer.materialize() produces an independent in-memory copy the task can own safely. Finally, keeping a reference to the task matters more than it looks: the task manager takes ownership, but a task garbage-collected before it starts simply never runs — the single most common cause of "my task does nothing".
The full treatment, including tasks that depend on other tasks and how to return a layer to the project, is in Run a Long Task with QgsTask in PyQGIS.
Faster beats asynchronous
Threading is a way to hide a slow operation. Making it fast is better, and in PyQGIS the same three causes account for most slowness.
Reading attributes and geometry you do not need. A QgsFeatureRequest that sets NoGeometry and a subset of attributes can cut iteration time by an order of magnitude on a wide table, because the provider stops serialising columns nobody reads.
Doing spatial comparisons without an index. Comparing every feature against every other feature is quadratic; a QgsSpatialIndex turns the inner loop into a candidate lookup, as shown in Build a Spatial Index in PyQGIS.
Editing without a transaction boundary. Committing per feature makes every write a round trip. Batch them, or use the provider directly.
Edits belong in batches
The third common cause of a frozen plugin is not computation at all — it is committing. Every commitChanges() is a transaction, and a transaction per feature turns a thousand small writes into a thousand round trips.
layer.startEditing()
for feature in layer.getFeatures():
layer.changeAttributeValue(feature.id(), field_index, compute(feature))
layer.commitChanges() # once, at the end
Breakdown: Opening the edit session once and committing once lets QGIS send the whole change set as a single transaction. The version of this loop with startEditing() and commitChanges() inside the loop is functionally identical and, on a PostGIS layer, roughly two orders of magnitude slower. For a bulk update with no need for undo, skip the edit buffer entirely and call dataProvider().changeAttributeValues() with a dictionary of changes — the approach used in Update Attribute Values in PyQGIS.
The same principle governs the canvas. Each triggerRepaint() schedules a redraw, so calling it inside a loop over fifty layers asks for fifty renders of a canvas nobody has seen yet. Change everything, then repaint once.
Caching what you look up repeatedly
A surprising share of plugin slowness is lookup, not work. Three of them recur often enough to be worth naming.
Layer lookups. QgsProject.instance().mapLayersByName("roads") walks the project's layer registry and builds a list. Inside a per-feature loop that is thousands of walks; hoist it above the loop, or hold the layer id and use mapLayer(layer_id), which is a dictionary lookup.
Field indexes. feature["area_m2"] resolves the name to an index on every access. In a tight loop, resolve it once with layer.fields().indexOf("area_m2") and index by number.
Coordinate transforms. Constructing a QgsCoordinateTransform is expensive because it initialises PROJ machinery; reuse one object for the whole loop rather than building it per point.
fields = layer.fields()
area_index = fields.indexOf("area_m2")
ref_index = fields.indexOf("parcel_ref")
for feature in layer.getFeatures(request):
attributes = feature.attributes()
process(attributes[ref_index], attributes[area_index])
Breakdown: attributes() returns the row's values as a plain list once, after which access is a list index rather than a name resolution. Resolving the two indexes before the loop makes the intent explicit and survives a schema change more gracefully than hard-coded numbers, because indexOf() returns -1 for a missing field — worth asserting on before the loop rather than discovering as a confusing IndexError on the first feature.
Measure before you optimise
Intuition about which line is slow is wrong often enough to be worth distrusting. cProfile works normally inside the QGIS Python console and inside a plugin, and a single run against real data usually shows that ninety percent of the time is in one call — frequently a geometry conversion inside a loop, or a mapLayersByName() lookup that should have happened once. Profile Slow PyQGIS Code covers the mechanics and how to read the output.
When a background task is the wrong answer
Threading has a cost — in complexity, in the classes of bug it introduces, and in the reviewer's time. Three situations look like they need a task and do not.
Work under a second. A progress bar that flashes and disappears is worse than no feedback at all. Below roughly one second, do the work inline and set a busy cursor.
Work the user must wait for anyway. If nothing else can sensibly be done until the result arrives — the dialog's next step depends on it, and the map is meaningless until it updates — a modal operation with a progress dialog is honest and much simpler. Moving it to a task and then disabling the whole interface achieves the same thing with more code.
Work that is slow because it is wrong. A per-feature loop doing a spatial comparison against another layer is not slow because it is on the main thread; it is slow because it is quadratic. Threading it hides a two-minute wait behind a progress bar that will become a twenty-minute wait when the data grows. Fix the algorithm.
The honest test is whether the user has something useful to do while the work runs. If they can keep panning the map, inspecting other layers or setting up the next operation, a task earns its complexity. If they will sit and watch the bar, it probably does not.
Testing code that runs on a task
Task code is ordinary code that happens to be called from another thread, and it is testable if you keep the two separable.
def summarise_areas(source, is_cancelled=lambda: False, on_progress=lambda p: None):
"""Pure function: no widgets, no project, no QgsTask."""
total = 0.0
count = source.featureCount() or 1
for index, feature in enumerate(source.getFeatures()):
if is_cancelled():
return None
total += feature.geometry().area()
if index % 500 == 0:
on_progress(100 * index / count)
return total
class AreaSumTask(QgsTask):
def run(self):
self.result = summarise_areas(self.source, self.isCanceled, self.setProgress)
return self.result is not None
Breakdown: The work lives in a function that knows nothing about QGIS's task machinery — it takes a feature source and two callables, and returns a number or None. That function can be tested directly against a memory layer with no application, no threading and no waiting, using the fixtures described in Unit Test a QGIS Plugin with pytest. Cancellation is testable too: pass a callable that returns True after the third call and assert the function stops. The task subclass shrinks to plumbing, which is the part least worth testing and most worth keeping small.
The same separation pays off when the requirements change. Work written this way can be called inline from a script, from a Processing algorithm, or from a task, without being rewritten for each.
Ordering several tasks
Real plugin work is often a sequence: download, process, then style and add to the project. Running those as three independent tasks is wrong — the second would start before the first has produced anything — and running them as one long run() throws away the manager's ability to report each stage.
The task manager understands dependencies, so declare them rather than coordinating by hand:
from qgis.core import QgsApplication
download = DownloadTask("Fetching survey data")
process = ProcessTask("Cleaning geometry")
process.addSubTask(download, [], QgsTask.ParentDependsOnSubTask)
QgsApplication.taskManager().addTask(process)
self.tasks = [download, process]
Breakdown: addSubTask() with ParentDependsOnSubTask tells the manager that the parent must not start until the subtask has completed successfully — and, importantly, that a failed subtask cancels the parent rather than letting it run on missing input. Only the parent is added to the manager; the subtask comes with it. Progress reported by the parent covers the whole group, so the user sees one entry rather than three competing bars. Keeping a reference to both remains necessary for the same garbage-collection reason as a single task.
Where the stages are genuinely independent — three regions processed in parallel — add them as separate tasks with no dependency and let the manager run them concurrently up to its thread limit. A fan-out like that is the one case where threading gives a real speed-up rather than only responsiveness, because the work actually proceeds in parallel.
What the manager deliberately does not give you is a guarantee about which thread a task runs on, or that two tasks will not run simultaneously. Anything shared between tasks — a file being written, a layer being edited — needs to be owned by exactly one of them.
Key takeaways
- Never touch widgets or project layers from
run(). Prepare on the main thread, compute on the task thread, apply infinished(). - Keep a reference to the task you hand to the manager, or it may be collected before it starts.
- Check
isCanceled()inside every loop. A cancel button that does not stop anything is worse than none. - Try a narrower
QgsFeatureRequestfirst. Dropping geometry and unused attributes often removes the need for threading entirely. - Profile before optimising. The slow line is rarely the one you would have guessed.
- Batch every edit. One
startEditing()and onecommitChanges()around the whole loop, never one per feature — the per-feature version is two orders of magnitude slower on a database layer. - Report progress at a human rate — every few hundred features, not every one; the signal itself costs time.
Frequently Asked Questions
How slow is slow enough to need a task? Roughly a second of blocked interface is where users start to notice, and three seconds is where they start clicking again. Between one and three seconds a busy cursor is usually enough; beyond that, move the work off the main thread or make it faster.
Does a task make my plugin harder to test?
Only if the work lives inside run(). Keep the computation in a plain function that takes a feature source and returns a value, and the tests never touch threading at all — the task subclass becomes a thin wrapper worth about four lines of coverage.
What happens to a running task when QGIS closes?
The task manager asks each task to cancel and waits briefly. A task that checks isCanceled() frequently exits cleanly; one that does not can delay shutdown or be terminated mid-write, which is another reason the check belongs in every loop.
Can I show results from a task that the user cancelled? Partial results are usually misleading, and presenting them as if the operation completed is worse than discarding them. If partial output is genuinely useful — a long export that wrote most of its features — say so explicitly in the message rather than letting it look finished.
Can I use Python's threading module instead of QgsTask?
You can start a thread, but you inherit every ownership rule with none of the integration: no entry in the task manager, no progress in the status bar, no cancellation, and no guarantee about which thread your callbacks run on. QgsTask exists precisely to make that correct.
Why does my task never run?
The task object was garbage-collected. Store it on self, or use QgsApplication.taskManager().addTask() and keep the reference until finished() fires.
Can a task run a Processing algorithm?
Yes — processing.run() works inside run() provided the inputs are file paths or layers the task owns. QgsProcessingAlgRunnerTask wraps this and handles the feedback plumbing for you.
How many tasks run at once? The task manager runs several concurrently, bounded by a thread count QGIS manages. Tasks that must run in order should declare dependencies rather than assume the manager's ordering.
Is a background task useful in a script rather than a plugin? Rarely. A headless script has no interface to keep responsive, so a straight loop is simpler and easier to debug — see Headless QGIS and Server Automation.
Does moving work to a task make it faster? No. It makes the interface responsive while the work happens. Speed comes from doing less work, which is what feature requests, spatial indexes and batched edits are for.
Related Guides
- Up: QGIS Plugin Development — the parent guide for this topic
- Plugin Boilerplate and Structure
- Qt Designer for GIS Interfaces
- Custom Map Tools and Canvas Interaction
- Testing and CI for Plugins
- Run a Long Task with QgsTask in PyQGIS
- Show Progress and Support Cancellation in a QGIS Plugin
- Speed Up Feature Iteration with QgsFeatureRequest
- Profile Slow PyQGIS Code