Extending QGIS with Custom Classes

Most plugins add things around QGIS: a toolbar button, a dialog, a dock widget, a Processing algorithm. A smaller but powerful set of plugins extend QGIS from the inside. They add a new kind of symbol to the symbol selector, a new renderer to the styling panel, a new page to the Layer Properties dialog, a new source of layers to the provider registry. Users experience these as part of QGIS itself, not as a plugin — which is exactly the point.

This guide belongs to QGIS Plugin Development. It is for plugin developers who have built a basic plugin and now need QGIS's own machinery to do something it does not do out of the box. It maps the extension points available to Python, explains the patterns they share — subclass, register, clean up — and the rules about threads and ownership that decide whether an extension is solid or crashes QGIS.

Extension points by areaSix extension points grouped by the part of QGIS they extend. Rendering: custom symbol layers and custom feature renderers, registered with the symbol layer and renderer registries. Map canvas: custom canvas items drawn above the map. Interface: layer tree view indicators and layer properties pages registered through iface. Data: Python vector data providers registered with the provider registry. Each follows the pattern subclass, register at plugin load, remove at unload.Where Python can plug into QGISrenderingsymbol layersfeature renderersregistriesmap canvascanvas itemsgraphics sceneinterfacelayer tree indicatorsproperties pagesifacedataPython vector data providersprovider registrysubclass → register in initGui → remove in unload

What this guide covers

The shared pattern: subclass, register, clean up

Every extension point works the same way at the top level, and getting the pattern right matters more than the details of any one class.

Subclass a QGIS base class and implement the methods QGIS will call: renderPoint for a symbol layer, symbolForFeature for a renderer, paint for a canvas item, fetchFeature for a provider iterator, createWidget for a properties page factory. QGIS calls these methods — often many times, sometimes from other threads — so they must do exactly what the contract says and nothing more.

Register the class with the part of QGIS that needs to know about it: the symbol layer registry, the renderer registry, the provider registry, the layer tree view, or iface. Registration usually goes through a small metadata or factory object that names the type and creates instances. It happens in the plugin's initGui, every session — QGIS does not remember Python registrations between runs.

Clean up in unload: remove registered types, unregister factories, remove indicators and canvas items, disconnect signals. A plugin that registers without unregistering leaves duplicates behind when reloaded during development, and can crash QGIS when the Python objects behind a registration are destroyed.

class MyExtensionsPlugin:
    def __init__(self, iface):
        self.iface = iface
        self._cleanup = []

    def initGui(self):
        from qgis.core import QgsApplication
        meta = MySymbolLayerMetadata()
        QgsApplication.symbolLayerRegistry().addSymbolLayerType(meta)
        self._keep = [meta]                            # keep Python objects alive

        factory = MyPropertiesPageFactory()
        self.iface.registerMapLayerConfigWidgetFactory(factory)
        self._keep.append(factory)
        self._cleanup.append(lambda: self.iface.unregisterMapLayerConfigWidgetFactory(factory))

    def unload(self):
        for undo in reversed(self._cleanup):
            undo()
        self._cleanup.clear()
        self._keep = []

Breakdown: Keeping every metadata and factory object in a list on the plugin prevents Python's garbage collector from destroying objects QGIS still points to — the most common cause of mysterious crashes in extension plugins. Recording an undo action for each registration and running them in reverse order in unload guarantees symmetry: whatever was added is removed, in the opposite order. The same structure scales to plugins that register several extension types at once.

Rendering extensions: symbols and renderers

QGIS's styling system has two levels, and Python can extend both.

A symbol layer draws one mark for one feature: a marker, a line style, a fill pattern. Built-in symbol layers cover most needs, and geometry generators plus data-defined properties cover many more. A custom symbol layer is for marks no combination of those can draw — a wind barb computed from two attributes, a domain-specific glyph, a tiny chart. It subclasses QgsMarkerSymbolLayer, QgsLineSymbolLayer or QgsFillSymbolLayer, draws with QPainter in renderPoint, renderPolyline or renderPolygon, and serialises its settings through properties().

A feature renderer decides which symbol each feature gets. Built-in renderers — categorized, graduated, rule-based — cover nearly everything when combined with expressions. A Python renderer is for decisions an expression cannot make: lookups in external systems, state across features, or an organisation's symbology scheme maintained as code. It subclasses QgsFeatureRenderer, implements symbolForFeature, and must implement save and create to survive a project round trip.

Symbol layers and renderersA feature renderer receives each feature and chooses a symbol. The symbol is a stack of symbol layers, each drawing part of the mark. A custom renderer replaces the choosing; a custom symbol layer replaces part of the drawing. Both run inside the rendering engine, possibly on worker threads, so they must prepare state in startRender and avoid touching the GUI.Choose a symbol, then draw itfeatureattributesgeometryrenderersymbolForFeature()custom: your rulessymbolsimple markercustom symbol layeroutlineboth run in the rendering engine — prepare in startRender, no GUI access

Both run inside the rendering engine, which renders layers in parallel worker threads. That has one hard consequence: the drawing and choosing code must not touch GUI objects, the project or the layer itself, and should read only state prepared in startRender. Both are also called once per feature on every redraw, so per-feature code must be lean — Python extensions are comfortable with thousands of features and sluggish with hundreds of thousands.

Canvas extensions: items above the map

The map canvas is a Qt graphics scene: the rendered map is one image in it, and canvas items sit on top. QGIS's own vertex markers, rubber bands and annotations are canvas items, and plugins can add their own by subclassing QgsMapCanvasItem. Canvas items are the right tool for graphics that are temporary, interactive and tied to a tool — a range ring around a click, a live GPS crosshair, a measurement readout — because they repaint instantly without re-rendering any layer.

The essential discipline is coordinate handling. An item stores its anchor in map coordinates and converts to pixels in updatePosition, which the canvas calls after every pan and zoom; it paints in pixels; and it declares the area it paints in boundingRect, calling prepareGeometryChange() before that area changes. Items do not appear in print layouts and are not saved with the project — for that, use layers or annotations. The canvas item recipe also shows how to pair an item with a map tool, which handles input while the item handles drawing.

Interface extensions: indicators and properties pages

Two extension points put a plugin's information where users already look.

Layer tree indicators are the small icons beside layers in the Layers panel. QGIS uses them for edit mode, filters and CRS problems; plugins can add their own for stale data, failed validation, sync state or anything else that should catch the eye. An indicator has an icon, a tooltip and a click signal, and is attached to a layer tree node. The work is in the bookkeeping: evaluating a rule per layer, updating rather than duplicating indicators, re-running on layer and project events, and removing everything on unload.

Layer properties pages add a page to the Layer Properties dialog — and optionally the Layer Styling panel — through a QgsMapLayerConfigWidgetFactory. They are the natural home for per-layer plugin settings, stored as layer custom properties so they travel with the project. A factory decides which layers get the page, so raster-only settings never clutter vector layers and vice versa.

from qgis.core import QgsProject

layer = QgsProject.instance().mapLayersByName("parcels")[0]
layer.setCustomProperty("assetsync/enabled", True)
layer.setCustomProperty("assetsync/id_field", "parcel_id")
print(layer.customPropertyKeys())

Breakdown: Custom properties are the glue between interface extensions: a properties page writes them, an indicator reads them to decide what to show, and the plugin's logic reads them to decide what to do. A plugin-specific prefix such as assetsync/ keeps keys from colliding with other plugins and makes them easy to find in the project file.

Data extensions: Python providers

The deepest extension point is the data provider. Every vector layer gets its features from a provider, and a Python provider makes any source — an internal REST API, a sensor feed, an object model in another application — appear as a normal layer that styles, labels, filters and runs through Processing like any other.

Provider, source and iteratorA Python provider has three cooperating classes. The provider describes the layer: fields, geometry type, CRS, extent and capabilities. The feature source is a thread-safe snapshot handed to rendering threads. The iterator fetches features for one request, honouring fid, rectangle, expression and destination CRS filters. Registration links a provider key to a factory.Describe, snapshot, fetchproviderfields · wkbType · crsextent · capabilitiesfeature sourcethread-safesnapshotiteratorfetchFeature()honours the requestregister: QgsProviderMetadata(key, description, factory)

Providers are the most code of any extension here — three classes and a registration — and the most rewarding when they fit. They fit when data is too large to copy and is viewed in pieces, when it changes and should be read live, or when projects must reopen pointing at the source. They do not fit when the data is small and a snapshot is fine: a memory layer filled from the same client is a fraction of the code. The iterator is where correctness lives: it must honour feature id filters, rectangle filters, expression filters and destination CRS requests, because the canvas, the identify tool, selections and Processing all depend on them.

Threads and ownership: the two rules that prevent crashes

Python extensions crash QGIS for two reasons far more often than any other.

Thread rule. Rendering happens on worker threads. Symbol layers, renderers and provider iterators may be called from them, so they must not touch GUI objects, the project, the layer object or any shared mutable Python state. Prepare what you need in startRender (or copy it into a feature source) and read only that. Canvas items, indicators and properties pages, by contrast, live on the main thread and must only be touched from it — background work hands results back through signals.

Ownership rule. QGIS's C++ objects and Python's objects have separate lifetimes. When QGIS holds a pointer to a Python-created object — a metadata object in a registry, a factory, a canvas item in the scene — Python must keep a reference for as long as QGIS uses it, and must remove it from QGIS before dropping that reference. Conversely, once QGIS deletes an object, any Python reference to it is dead and raises "wrapped C/C++ object has been deleted". Object ownership and crashes explains the mechanics in depth.

import threading
from qgis.PyQt.QtCore import QThread
from qgis.core import QgsApplication

def assert_main_thread():
    if QThread.currentThread() != QgsApplication.instance().thread():
        raise RuntimeError("must run on the main thread")

def debug_thread(label):
    print(label, "on", threading.current_thread().name)

Breakdown: Two small helpers make threading visible during development: an assertion that fails loudly when main-thread-only code runs elsewhere, and a print that shows which thread a callback runs on. Calling debug_thread inside renderPoint or fetchFeature while panning shows worker-thread names, which makes the thread rule concrete. Remove the prints before release; keep the assertion in methods that touch the GUI.

Testing extensions

Extensions are easiest to test at the level of their contract, without the GUI. A renderer's symbolForFeature can be called with synthetic features; a provider can be loaded as a layer and queried with every kind of request; a symbol layer can be rendered into an image with QgsMapRendererSequentialJob and compared against a reference. These tests run headless in CI with the QGIS Docker images, as described in running plugin tests in GitHub Actions. Interface extensions — indicators and properties pages — are best tested by exercising their logic (the rule, the settings helper) separately from the widgets.

Choosing the right extension point

Before writing a custom class, check whether configuration can do the job, because configuration needs no plugin and survives in any QGIS.

  • A geometry generator or data-defined properties often replace a custom symbol layer.
  • A rule-based renderer with expressions replaces most custom renderers.
  • Annotations and rubber bands cover many canvas graphics.
  • Layer notes and metadata can carry per-layer information without a properties page.
  • A memory layer or virtual layer replaces a provider for small or derived data.

Reach for a custom class when the logic genuinely needs Python, when it must be shared and maintained as code, or when users need it to feel like a native part of QGIS.

Distribution considerations

Extensions that change how projects render have a compatibility cost: a project using a custom symbol layer or renderer only displays correctly where the plugin is installed and enabled. Document this for users, consider providing a fallback style, and declare the plugin as a dependency in any tooling that opens such projects — declaring plugin dependencies covers the metadata. For QGIS Server, the plugin must be installed as a server plugin too. Extensions that only add interface elements — indicators, pages, canvas items — carry no such cost, because projects do not depend on them.

A development workflow for extensions

Extension plugins are harder to iterate on than ordinary ones, because registrations persist until removed and some cannot be removed at all on older releases. A workflow that keeps the loop short:

  1. Prototype in the Python console. Define the class, register it, test it on a layer, and unregister it — all without a plugin. Most bugs show up here in minutes.
  2. Move it into a plugin with symmetric initGui and unload. Use Plugin Reloader to reload after edits, and check that nothing is duplicated after a reload.
  3. Restart QGIS when changing a registered type's name or properties format. Registries and open projects may hold the old definition.
  4. Test the contract headless. Call the methods QGIS calls, render to images, load providers as layers — in pytest, in CI.
  5. Open a saved project in a fresh QGIS. This is the only reliable check that serialisation works and that the plugin loads before projects need it.

Key takeaways

  • Every extension follows subclass, register in initGui, remove in unload.
  • Keep references to every metadata, factory and item QGIS points to; remove them from QGIS before dropping them.
  • Rendering and provider code runs on worker threads — prepare state up front and never touch the GUI there.
  • Symbol layers draw marks; renderers choose symbols; both must serialise to survive saving.
  • Canvas items store map coordinates and paint in pixels; indicators and properties pages put plugin state where users look.
  • Python providers make any source a native layer — worth it for large or live data, overkill for small snapshots.
  • Try configuration first; write a custom class when the logic truly needs Python.

Frequently Asked Questions

Can these extensions be written in C++ instead? Yes, and C++ is faster for rendering and providers. Python is quicker to write and distribute through the plugin repository; most plugin authors start in Python and move hot paths to C++ only if needed.

Do Python extensions work in QGIS 4? Yes. The extension points are unchanged; scripts need the usual QGIS 4 adjustments for scoped enums and PyQt6, noted in each recipe and in porting a plugin to QGIS 4.

What happens to a project if my plugin is missing? Unknown symbol layer types and renderers fall back to defaults with a warning; layers from a missing provider load as invalid. Interface extensions simply do not appear.

Can I extend the attribute table or forms the same way? Forms have their own extension mechanism — Python init code and custom editor widgets — covered in attribute forms and layer actions.

Where else can plugins plug in? Processing providers, locator filters, expression functions, map tools, options pages, browser data items and source-select dialogs are further extension points, several covered elsewhere in this section.