Adding Custom Icons and Tooltips to QGIS Toolbar Buttons

How to attach a custom QIcon and tooltip to a QGIS toolbar button using QAction, with path validation, fallback logic, garbage-collection prevention, and…

TL;DR: Instantiate a QAction with a QIcon loaded from a validated absolute path, call setToolTip() on it, pass iface.mainWindow() as the parent, register it with iface.addToolBarIcon() inside initGui(), and keep a reference in self.actions — without that reference Python’s garbage collector silently destroys the button.

This page is part of the Integrating Toolbars and Menu Actions guide, which covers the full action registration and teardown workflow within Plugin Development & UI Integration.


Complete Drop-in Implementation

The class below handles icon path resolution, a QgsApplication.getThemeIcon() fallback, lifecycle management, and clean unload. Drop it into any plugin that inherits the standard QGIS plugin template; it is tested against QGIS 3.10+ with both PyQt5 and PyQt6 bindings.

python
"""
toolbar_button_manager.py — production-ready toolbar button registration for QGIS plugins.
Register in initGui(), clean up in unload().
"""

import functools
from pathlib import Path
from typing import Callable, List, Optional

from qgis.PyQt.QtWidgets import QAction
from qgis.PyQt.QtGui import QIcon
from qgis.core import QgsApplication, QgsMessageLog, Qgis
from qgis.utils import iface


class ToolbarButtonManager:
    """
    Manages custom toolbar button registration and teardown for a QGIS plugin.

    Always store one instance on the main plugin class so Python's reference
    count never drops to zero while the plugin is loaded.
    """

    TAG = "MyPlugin"

    def __init__(self) -> None:
        # Strong references prevent Qt from garbage-collecting QAction objects
        # after the enclosing function scope exits.
        self.actions: List[QAction] = []

    def add_button(
        self,
        icon_path: str,
        tooltip: str,
        callback: Callable,
        *,
        object_name: str = "custom_action",
        callback_args: Optional[tuple] = None,
    ) -> QAction:
        """
        Register a toolbar button with a custom icon, tooltip, and callback.

        Args:
            icon_path:    Absolute or plugin-relative path to an SVG/PNG icon.
            tooltip:      Hover text shown above the button (also set as status tip).
            callback:     Python callable triggered on button click.
            object_name:  Qt object name — used for automated testing lookups.
            callback_args: Optional tuple of extra arguments forwarded to callback
                           via functools.partial.

        Returns:
            The registered QAction (caller may store it for later shortcut binding).
        """
        resolved = Path(icon_path).resolve()

        if resolved.exists():
            icon = QIcon(str(resolved))
        else:
            QgsMessageLog.logMessage(
                f"Icon not found at {resolved}; using theme fallback.",
                self.TAG,
                level=Qgis.Warning,
            )
            icon = QgsApplication.getThemeIcon("/mActionZoomFullExtent.svg")

        # iface.mainWindow() as parent: ensures the action joins the correct
        # Qt object tree and is destroyed with the main window on shutdown.
        action = QAction(icon, tooltip, iface.mainWindow())
        action.setObjectName(object_name)
        action.setToolTip(tooltip)
        action.setStatusTip(tooltip)  # written to the QGIS status bar on hover

        handler = (
            functools.partial(callback, *callback_args)
            if callback_args
            else callback
        )
        action.triggered.connect(handler)

        iface.addToolBarIcon(action)
        self.actions.append(action)
        return action

    def remove_all(self) -> None:
        """
        Deregister every managed action from the toolbar.
        Call this from the plugin's unload() method.
        """
        for action in self.actions:
            iface.removeToolBarIcon(action)
        self.actions.clear()

Wiring into initGui() and unload()

python
"""
my_plugin.py — minimal plugin skeleton demonstrating ToolbarButtonManager usage.
"""

import os
from qgis.utils import iface
from .toolbar_button_manager import ToolbarButtonManager


class MyPlugin:
    def __init__(self, iface_ref) -> None:
        self._iface = iface_ref
        self._toolbar = ToolbarButtonManager()

    def initGui(self) -> None:
        icon = os.path.join(os.path.dirname(__file__), "icons", "my_tool.svg")
        self._toolbar.add_button(
            icon_path=icon,
            tooltip=self.tr("Run spatial analysis workflow"),
            callback=self._run_analysis,
            object_name="my_plugin_analysis_action",
        )

    def unload(self) -> None:
        # Removes all buttons and releases Qt references cleanly
        self._toolbar.remove_all()

    def _run_analysis(self) -> None:
        iface.messageBar().pushSuccess("MyPlugin", "Analysis complete.")

Icon-to-Button Data Flow

The diagram below traces the path from an SVG file on disk through Qt’s rendering pipeline to the visible toolbar glyph. Understanding where each transformation occurs makes it straightforward to diagnose DPI or contrast failures.

Icon-to-toolbar data flow Flowchart showing: icon path is validated, then either loaded as QIcon or replaced by a theme fallback, then passed to QAction, which is registered by iface.addToolBarIcon and rendered in the QGIS toolbar. icon_path (str / Path) exists? yes no QIcon(path) SVG / PNG rasterised getThemeIcon() built-in fallback QAction icon + tooltip QGIS Toolbar

Architecture Breakdown

QIcon — resolution and DPI handling

QIcon accepts SVG, PNG, and ICO. SVG is the only format that scales correctly on 4K / HiDPI displays without supplying multiple raster sizes. Qt renders an SVG at the requested pixel density at draw time, so a single file covers all DPI targets. When loading from disk, always resolve to an absolute path with Path(__file__).parent / "icons" / "my_tool.svg" — relative paths are resolved against the current working directory, which changes between the Python Console and a loaded plugin.

To inspect what Qt materialised from your file at runtime:

python
from qgis.PyQt.QtGui import QIcon
icon = QIcon("/abs/path/my_tool.svg")
print(icon.availableSizes())  # [] means Qt could not parse the file

An empty list is the most reliable early-warning signal: it means Qt parsed nothing, the button will show a blank square, and no error is raised.

QAction — the bridge between icon and event

QAction is Qt’s unified command object: it carries the icon, the tooltip string, the enabled/checked state, and the triggered signal — all in one object that can be attached to both a toolbar and a menu simultaneously. Crucially, the parent argument controls lifetime: iface.mainWindow() is the correct parent for plugin actions because it is the one widget guaranteed to outlive every plugin load/unload cycle within a session.

Setting both setToolTip() and setStatusTip() covers two distinct surfaces: the floating bubble that appears after a short hover delay, and the text written to the left side of the QGIS status bar immediately on hover. Assistive technology reads the tooltip; the status tip reinforces context for users who disable hover pop-ups.

iface.addToolBarIcon() vs iface.addToolBar()

iface.addToolBarIcon(action) appends the action to the built-in Plugins toolbar — the strip where most third-party buttons land. This is appropriate for single-action plugins. For tools with three or more related buttons, call iface.addToolBar("My Plugin Toolbar") once in initGui() to create a dedicated strip, then attach actions with toolbar.addAction(action). The dedicated toolbar gives users a clear visual grouping and lets them dock or float the strip independently.

Garbage collection and the self.actions list

Python’s garbage collector destroys any object whose reference count reaches zero. A QAction created inside a function and not stored elsewhere is destroyed when the function returns — the button vanishes from the toolbar without error. The fix is one line: self.actions.append(action). An instance-level list keeps the reference alive for the full plugin lifetime. This is the single most common cause of “button appears for a moment then disappears” bug reports.


Registration and Integration Snippet

The pattern below is the minimal correct skeleton for a plugin that registers exactly one toolbar button. It covers the full plugin lifecycle contract: classFactoryinitGui → runtime → unload.

python
"""
plugin_main.py — minimal single-action plugin skeleton.
Handles i18n via self.tr(), safe path resolution, and deterministic teardown.
"""

import os
from qgis.gui import QgisInterface
from .toolbar_button_manager import ToolbarButtonManager


class SingleActionPlugin:
    """Minimal plugin demonstrating correct icon + tooltip registration."""

    def __init__(self, iface: QgisInterface) -> None:
        self.iface = iface
        self._toolbar = ToolbarButtonManager()

    def initGui(self) -> None:
        """Called by the plugin manager after the plugin module is imported."""
        icon_path = os.path.join(os.path.dirname(__file__), "icons", "tool.svg")
        self._toolbar.add_button(
            icon_path=icon_path,
            tooltip=self.tr("Export selected features to GeoPackage"),
            callback=self._on_export,
            object_name="single_action_export",
        )

    def unload(self) -> None:
        """Called when the user disables or uninstalls the plugin."""
        self._toolbar.remove_all()

    def _on_export(self) -> None:
        from qgis.utils import iface
        iface.messageBar().pushInfo("Export", "Starting GeoPackage export…")

    def tr(self, message: str) -> str:  # noqa: D401
        """Wrap user-facing strings for Qt Linguist translation pipelines."""
        from qgis.PyQt.QtCore import QCoreApplication
        return QCoreApplication.translate("SingleActionPlugin", message)

Production Best Practices

  • Use SVG icons exclusively. PNG icons blur on HiDPI displays. Strip metadata from SVGs with scour before bundling them; oversized SVG files slow plugin load time.
  • Wrap tooltip strings in self.tr(). Hardcoded English strings bypass QGIS translation pipelines. Even if you never ship translations, the wrapping keeps the plugin translation-ready.
  • Set setStatusTip() alongside setToolTip(). The status bar is the only tooltip surface visible to users who hover quickly or run QGIS with system animations disabled.
  • Validate the icon path before calling QIcon(). Silent blank buttons are harder to diagnose than a logged Qgis.Warning at startup. The ToolbarButtonManager above does this automatically.
  • Store all actions in self.actions. Never rely on Qt parentage alone to keep actions alive — the toolbar holds a reference to the button, not to your Python QAction wrapper.
  • Call iface.removeToolBarIcon() for every registered action in unload(). Orphaned buttons degrade performance over long QGIS sessions and confuse users who disable the plugin.
  • Use action.setObjectName(). A stable object name enables automated UI tests to find the button by name via iface.mainWindow().findChild(QAction, "my_object_name"), decoupling tests from position or icon.
  • Test in both the Python Console and as a compiled plugin. Console actions are ephemeral; plugin actions must survive the full load/unload cycle. Running the same registration code in both contexts catches lifetime bugs early.