Plugin Boilerplate & Structure
A well-defined plugin boilerplate and directory structure is the foundation of any maintainable PyQGIS extension. QGIS expects a predictable file hierarchy, standardized metadata, and explicit lifecycle hooks. When these elements are correctly implemented, the plugin manager can load, initialize, and unload your code without conflicts. This guide outlines the architectural standards, provides tested initialization patterns, and details the step-by-step workflow required to build a production-ready QGIS plugin.
For developers entering the ecosystem, understanding how QGIS Plugin Development operates at the framework level is essential before customizing the boilerplate. The structure below aligns with QGIS 3.x standards and Python 3 requirements.
Prerequisites
Before generating or modifying a plugin skeleton, ensure your environment meets the following baseline requirements:
- QGIS 3.x installed (LTS or current release)
- Python 3.9+ environment (bundled with modern QGIS releases)
- A code editor with Python syntax highlighting and linting (VS Code, PyCharm, or similar)
- Basic familiarity with object-oriented Python and Qt signal/slot architecture
- Write access to the QGIS profile directory
The QGIS profile directory typically resides at:
- Windows:
%APPDATA%\QGIS\QGIS3\profiles\default\python\plugins\ - macOS/Linux:
~/.local/share/QGIS/QGIS3/profiles/default/python/plugins/
Standard Directory Architecture
A compliant plugin directory must contain specific files that QGIS scans during startup. The following tree represents the minimal viable structure:
my_plugin/
├── __init__.py
├── metadata.txt
├── main_plugin.py
├── resources.qrc
├── ui/
│ └── main_dialog.ui
├── i18n/
│ └── my_plugin_en.ts
└── icons/
└── icon.png
File Responsibilities:
__init__.py: Entry point that exposes theclassFactoryfunction to QGIS.metadata.txt: Plain-text configuration file containing plugin name, version, author, and QGIS compatibility flags.main_plugin.py: Core logic handling GUI initialization, toolbar/menu integration, and cleanup routines.resources.qrc: Qt resource compiler file bundling icons and UI assets.ui/: Contains.uiXML files generated by Qt Designer.i18n/: Translation files for internationalization.icons/: Raster or SVG assets for toolbar buttons and plugin manager listings.
Step-by-Step Implementation Workflow
- Create the Root Directory: Name it using lowercase letters and underscores (e.g.,
spatial_analyzer). Avoid spaces or special characters. - Generate
metadata.txt: Populate required keys. QGIS will reject plugins missingname,version,qgisMinimumVersion, ordescription. - Implement
__init__.py: Define a singleclassFactoryfunction that returns your main plugin class instance. - Build
main_plugin.py: Define a standard Python class that acceptsiface. ImplementinitGui(),unload(), andrun(). - Compile Resources: Use
pyrcc5to convertresources.qrcinto a Python-importable module (resources_rc.py). - Load & Test: Enable the plugin in QGIS via
Plugins → Manage and Install Plugins → Installed → Enable. Monitor the Python Console for traceback output.
Core File Breakdown & Tested Code Patterns
1. Entry Point (__init__.py)
This file must be lightweight. QGIS calls classFactory(iface) during plugin discovery.
def classFactory(iface):
"""
Factory function required by QGIS.
:param iface: QgsInterface instance providing access to the QGIS API
:return: Main plugin class instance
"""
from .main_plugin import SpatialAnalyzerPlugin
return SpatialAnalyzerPlugin(iface)
2. Metadata Configuration (metadata.txt)
Use strict key-value formatting. Do not include blank lines between keys.
[general]
name=Spatial Analyzer
qgisMinimumVersion=3.16
description=Provides advanced spatial analysis tools for vector layers.
version=1.0.0
author=Your Name
email=your.email@example.com
about=This plugin extends QGIS with custom geoprocessing workflows.
tracker=https://github.com/yourname/spatial-analyzer/issues
repository=https://github.com/yourname/spatial-analyzer
icon=icons/icon.png
experimental=False
deprecated=False
3. Main Plugin Class (main_plugin.py)
The following pattern demonstrates safe GUI registration, robust path resolution, and clean teardown.
import os
from qgis.PyQt.QtCore import QSettings, QTranslator, QCoreApplication
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QAction
from qgis.core import Qgis
from qgis.gui import QgsMessageBar
class SpatialAnalyzerPlugin:
def __init__(self, iface):
self.iface = iface
self.plugin_dir = os.path.dirname(__file__)
self.actions = []
self.menu = self.tr("&Spatial Analyzer")
self.toolbar = self.iface.addToolBar("SpatialAnalyzer")
self.toolbar.setObjectName("SpatialAnalyzer")
# Initialize translation with safe fallback
locale = QSettings().value("locale/userLocale", "en_US")[0:2]
locale_path = os.path.join(self.plugin_dir, "i18n", f"spatial_analyzer_{locale}.qm")
if os.path.exists(locale_path):
self.translator = QTranslator()
self.translator.load(locale_path)
QCoreApplication.installTranslator(self.translator)
def tr(self, message):
return QCoreApplication.translate("SpatialAnalyzerPlugin", message)
def add_action(
self,
icon_path,
text,
callback,
enabled_flag=True,
add_to_menu=True,
add_to_toolbar=True,
status_tip=None,
whats_this=None,
parent=None,
):
icon = QIcon(icon_path)
action = QAction(icon, text, parent)
action.triggered.connect(callback)
action.setEnabled(enabled_flag)
if status_tip:
action.setStatusTip(status_tip)
if whats_this:
action.setWhatsThis(whats_this)
if add_to_toolbar:
self.toolbar.addAction(action)
if add_to_menu:
self.iface.addPluginToMenu(self.menu, action)
self.actions.append(action)
return action
def initGui(self):
"""Initialize GUI elements when plugin loads."""
icon_path = os.path.join(self.plugin_dir, "icons", "icon.png")
self.add_action(
icon_path,
text=self.tr("Run Analysis"),
callback=self.run,
parent=self.iface.mainWindow(),
)
def unload(self):
"""Remove GUI elements and clean up resources."""
for action in self.actions:
self.iface.removePluginMenu(self.menu, action)
self.iface.removeToolBarIcon(action)
# Safely remove the custom toolbar
self.iface.mainWindow().removeToolBar(self.toolbar)
self.actions.clear()
def run(self):
"""Execute plugin logic."""
layer = self.iface.activeLayer()
if not layer:
self.iface.messageBar().pushMessage(
"Warning",
"Please select a vector layer first.",
level=Qgis.Warning,
duration=3,
)
return
# Core processing logic goes here
self.iface.messageBar().pushMessage(
"Success",
f"Analysis initialized for {layer.name()}",
level=Qgis.Success,
duration=3,
)
Extending the Boilerplate
Once the base structure is stable, you can branch into specialized implementations. If your plugin requires field calculator integration, you will need to adapt the initialization sequence to support custom expression functions, which requires explicit registration with QgsExpression.registerFunction() during initGui() and deregistration in unload().
Common Errors & Resolution Strategies
1. Plugin Not Appearing in Manager
Symptom: The directory exists but QGIS ignores it.
Cause: Missing metadata.txt, malformed keys, or qgisMinimumVersion higher than the installed QGIS version.
Fix: Verify metadata.txt syntax. Ensure qgisMinimumVersion matches or is lower than your QGIS build. Check View > Log Messages > Python Errors for PluginManager warnings.
2. ModuleNotFoundError or Import Failures
Symptom: ImportError: cannot import name 'main_plugin'Cause: Relative import issues or missing __init__.py in subdirectories.
Fix: Always use relative imports (from .main_plugin import ...). Ensure every Python-containing directory has an empty __init__.py. Avoid absolute paths; QGIS modifies sys.path dynamically during plugin loading.
3. UI Dialog Fails to Load
Symptom: QFile::open: No such file or directory when loading .ui files.
Cause: Incorrect working directory assumption. QgsApplication does not guarantee the current working directory matches the plugin folder.
Fix: Resolve paths using os.path.dirname(__file__) before passing them to uic.loadUiType(). Example:
ui_path = os.path.join(os.path.dirname(__file__), "ui", "main_dialog.ui")
4. Toolbar/Menu Items Persist After Unload
Symptom: Buttons remain visible after disabling the plugin.
Cause: unload() does not explicitly remove registered actions.
Fix: Store all QAction instances in a list during initGui() and iterate through them in unload(), calling both removePluginMenu() and removeToolBarIcon(). Never rely on QGIS to garbage-collect UI references automatically.
5. Resource Compilation Errors
Symptom: pyrcc5 fails or icons appear as broken placeholders.
Cause: Invalid paths in resources.qrc or missing qrc file in the plugin directory.
Fix: Run compilation from the plugin root: pyrcc5 resources.qrc -o resources_rc.py. Verify all <file> tags in the .qrc use paths relative to the .qrc location.
Workflow Validation Checklist
Before considering the boilerplate complete, verify the following:
-
metadata.txtcontains all required fields and valid version strings -
__init__.pyexposes exactly oneclassFactoryfunction -
initGui()registers actions andunload()removes them symmetrically - All file paths resolve using
__file__rather thanos.getcwd() - Plugin loads without traceback in a fresh QGIS profile
- Toolbar and menu items disappear completely after disabling
- Translation files load gracefully when missing
Once validation passes, the structure is ready for advanced feature integration and distribution. Properly organizing your codebase at this stage prevents technical debt and simplifies the transition to packaging, where standardized archives, dependency management, and repository submission protocols take precedence.
Frequently Asked Questions
Why must __init__.py only contain the classFactory function and a deferred import?
QGIS imports __init__.py during plugin discovery, before the user enables the plugin. Keeping it lightweight and importing your main module inside classFactory (from .main_plugin import ...) prevents heavy modules from loading at scan time and stops an import error in one plugin from breaking the entire plugin manager.
What happens if metadata.txt is missing or has a malformed key?
QGIS silently skips the directory and the plugin never appears in the plugin manager. Required keys are name, version, qgisMinimumVersion, and description; missing any one, or adding blank lines between keys, causes the parser to reject the file. Check View > Log Messages > Python Errors for PluginManager warnings when a folder is ignored.
Do I need to compile .ui files into the plugin structure?
No. The recommended pattern loads the .ui file at runtime with uic.loadUiType(), so you ship the raw .ui inside the ui/ folder rather than a compiled module. You only run pyrcc5 to turn resources.qrc into resources_rc.py, since Qt's resource system cannot read .qrc directly at runtime.
Where should the plugin folder live during development?
Place it in the active QGIS profile's python/plugins/ directory — %APPDATA%\QGIS\QGIS3\profiles\default\python\plugins\ on Windows or ~/.local/share/QGIS/QGIS3/profiles/default/python/plugins/ on macOS/Linux. The folder name becomes the import package name, so use lowercase letters and underscores with no spaces.
Why do toolbar buttons sometimes remain after I disable a plugin?
QGIS does not automatically garbage-collect QAction references you added in initGui(). Store every action in a list and, in unload(), call both removePluginMenu() and removeToolBarIcon() for each, then remove any custom toolbar you created. Symmetric setup and teardown is the only reliable way to keep the interface clean.