QGIS Object Ownership and Crashes in PyQGIS
A Python script should not be able to crash the application it runs in, and in most Python it cannot. PyQGIS is different: it is a binding over a large C++ library, and when a Python object is deleted while C++ still holds a pointer to it, QGIS does not raise — it segfaults, with no traceback and no message. Understanding the two ownership rules that cause almost all of these is a couple of hours that will save you days.
This recipe belongs to QGIS API Architecture. It covers how SIP maps C++ objects into Python, which calls transfer ownership, the three crash patterns you will actually hit, and how to make each impossible.
Prerequisites
- QGIS 3.34 LTR or newer. Nothing here is version-specific; it is how the bindings work.
- Familiarity with Python's reference counting — an object is deleted when the last reference to it goes.
How the bindings work
QGIS's C++ API is exposed to Python by SIP, which creates a thin Python wrapper around each C++ object. The wrapper holds a pointer and a flag saying who is responsible for deleting the underlying object. Most of the time that arrangement is invisible. It becomes visible in exactly two places: when you create an object in Python and hand it to QGIS, and when QGIS hands you an object it still owns.
The rule for the first case is that some methods take ownership and some do not, and the documentation marks the ones that do. QgsProject.addMapLayer() takes ownership of the layer. QgsMapCanvas.setMapTool() does not. That single difference is why adding a layer to the project and forgetting about it is fine, while creating a map tool and forgetting about it crashes the moment the user clicks the canvas.
Crash pattern one: the collected map tool
from qgis.gui import QgsMapToolEmitPoint
# WRONG — the tool is deleted as soon as this scope ends
def enable_picking(canvas):
tool = QgsMapToolEmitPoint(canvas)
tool.canvasClicked.connect(lambda point, button: print(point))
canvas.setMapTool(tool)
Breakdown: setMapTool stores a bare pointer; it does not take ownership and does not add a Python reference. When enable_picking returns, tool is the only reference and it disappears, the wrapper is collected, the C++ object is deleted, and the canvas is left pointing at freed memory. The crash comes on the next click, which makes it look like a click-handling bug rather than a lifetime bug. The fix is to keep the reference alive as long as the tool is in use:
class Picker:
def __init__(self, canvas):
self.canvas = canvas
self.tool = QgsMapToolEmitPoint(canvas)
self.tool.canvasClicked.connect(self.on_click)
def enable(self):
self.canvas.setMapTool(self.tool)
def on_click(self, point, button):
print(point.x(), point.y())
Breakdown: Holding the tool on an instance that itself lives — a plugin object, typically — keeps the reference count above zero for as long as the tool can be used. The same rule applies to QgsRubberBand, QgsVertexMarker, QgsMapCanvasItem and every other canvas item: the canvas draws them and does not own them. Anything you create and hand to the canvas needs a home. The interaction between this and map tool design is covered in creating a custom map tool.
Crash pattern two: using a layer after the project deleted it
layer = QgsVectorLayer("/data/roads.gpkg", "roads", "ogr")
QgsProject.instance().addMapLayer(layer)
QgsProject.instance().removeMapLayer(layer.id())
print(layer.featureCount()) # crash — the C++ object is gone
Breakdown: addMapLayer transfers ownership to the project, so the project's removeMapLayer deletes the underlying object while the Python name layer still points at a wrapper for freed memory. Python cannot tell — the wrapper looks fine until you touch it. The habit that avoids it is to stop using a variable after removing the layer it names, and to look layers up from the project by id or name at the point of use rather than holding them across operations that might remove them.
Crash pattern three: the dangling signal connection
class Watcher:
def __init__(self, layer):
layer.featureAdded.connect(self.on_added)
def on_added(self, feature_id):
self.update_panel(feature_id) # self.panel may be gone
Breakdown: A signal connection keeps the slot's object alive from Qt's perspective only if the receiver is a QObject with a proper parent; a plain Python object connected this way can be collected while the connection survives, and the next emission calls into nothing. Worse, on plugin reload the old instance's connections are still live, so one signal fires two handlers and the second touches a destroyed widget. The discipline is to disconnect explicitly in unload:
def teardown(self, layer):
try:
layer.featureAdded.disconnect(self.on_added)
except TypeError:
pass
Breakdown: disconnect raises TypeError if the connection is already gone, which happens routinely when the layer was removed first, so swallowing that specific exception is correct rather than lazy. Doing this for every connection a plugin makes is what allows the plugin to be reloaded during development without accumulating handlers — the mechanics of signals generally are covered in understanding QGIS signals and slots.
Diagnosing a crash you already have
There is no traceback, so the technique is bisection plus one diagnostic. Run QGIS from a terminal so any C++ output reaches you, and note the last thing the script did before dying — a crash on canvas interaction points at a collected canvas object, a crash on project close points at ownership, and a crash on plugin reload points at connections.
import sip
if sip.isdeleted(layer):
print("the underlying C++ object is gone")
Breakdown: sip.isdeleted() is the one direct check available and it is worth knowing: it reports whether the wrapper's C++ object has been destroyed, letting a defensive guard turn a crash into a handled case. It is a diagnostic rather than a design — code that needs it in production usually has a lifetime problem to fix rather than to detect — but during debugging it converts "it crashes sometimes" into a precise answer.
Writing code that cannot hit these
Three habits remove almost all the risk without any cleverness.
Give every long-lived object an owner object. A plugin class that holds its tools, its rubber bands, its timers and its dock widgets as attributes has, by construction, solved the reference problem for all of them. The rule is easy to apply because it needs no judgement about which calls transfer ownership: if you made it and QGIS is going to use it later, put it on self.
Look layers up at the point of use. QgsProject.instance().mapLayersByName("roads") at the top of each function is a dictionary lookup and costs nothing, while a layer reference held on an instance across an unknown span of user activity is a bet that nobody removed it. Where the lookup returns an empty list, you get a clean Python error instead of a crash.
Pair every connect with a disconnect and every add with a remove. Writing the teardown at the same time as the setup, rather than afterwards, is what makes it complete — teardown written later is teardown written from memory.
None of these costs anything in performance or clarity, and together they turn a class of unexplained crashes into a class of ordinary Python errors that tell you what went wrong.
QGIS version compatibility
None of this changes across QGIS 3 releases, because it is a property of the SIP bindings rather than of the API. What does change is which methods transfer ownership, occasionally, when a signature is revised; the C++ documentation marks transfer with an annotation, and the Python documentation carries it through. sip.isdeleted has been available throughout.
Troubleshooting
- QGIS closes instantly with no message. A segfault. Run from a terminal to see any output.
- Crash on the first canvas click after enabling a tool. The tool was not kept referenced.
- Crash when closing the project. A variable still naming a layer the project deleted.
- Crash on plugin reload. Signal connections from the previous instance still live.
- A rubber band disappears immediately. Collected — store it on the plugin instance.
RuntimeError: wrapped C/C++ object has been deleted. The friendly version of the same bug; Qt widgets report it rather than crashing.
Conclusion
Two rules cover almost everything: keep a Python reference to anything you create and hand to the canvas, and stop using a variable once the project owns and may delete what it names. Add a disciplined disconnect on unload and the third pattern disappears too. Crashes in PyQGIS feel mysterious precisely because there is no traceback, but the causes are few and each has a mechanical fix.
Frequently Asked Questions
Why does the same code work in the console and crash in a plugin? The console keeps every name in a module-level namespace that persists, so objects that would be collected in a function stay alive. Moving working console code into a method is a classic way to introduce this bug.
Does del on a layer variable delete the layer?
Only if Python still owns it. Once the project owns it, del drops the wrapper and leaves the layer in the project.
Is there a way to force ownership?sip.transferto() and sip.transferback() exist, and reaching for them is nearly always a sign that a reference should be kept somewhere sensible instead.
Do these problems affect Processing scripts? Less often, because algorithms create and consume objects within one call. The risk rises with anything that outlives a single function — tools, timers, panels and connections.