Packaging and Distributing QGIS Plugins
Package a QGIS plugin for distribution — metadata.txt fields, resource compilation, zip layout, semantic versioning, and publishing to the official QGIS…
A plugin that runs perfectly in your development profile is only half finished. Before anyone else can install it, that working code has to become a correctly structured package: an INI-format metadata.txt QGIS can parse, compiled Qt resources so icons resolve without loose files, a zip archive with exactly the right root folder, and a version string that the Plugin Manager can compare against what a user already has. Get any of these wrong and the install silently fails, the icon renders blank, or the update never surfaces. This page, part of the Headless Automation, CI/CD & Testing for PyQGIS guide, walks through the plugin directory contract, what QGIS reads at load time, resource compilation, the zip layout, semantic versioning discipline, and publishing to the official repository so your plugin installs cleanly for every user.
Prerequisites Checklist
Before you package anything, confirm the following are in place:
- A tested plugin: Packaging is a release step, not a debugging one. Your plugin should already pass its test suite — see Testing PyQGIS with pytest-qgis — and load without errors in a development profile.
- A build tool or a manual build script:
pb_tool(pip install pb_tool) automates compilation, zipping, and deployment. If you prefer no dependencies, a short shell or Python build script works equally well; both are shown below. - QGIS 3.28+ (LTR): The
metadata.txtfields and Plugin Manager behaviour described here target the 3.28 long-term release and later. Declare your true minimum inqgisMinimumVersion. - Qt tooling for resource compilation:
pyrcc5ships with the PyQt5 development tools bundled in most QGIS installations. Confirm it is on yourPATHbefore building. - An OSGeo plugin author account: Publishing to the QGIS Plugin Repository requires an OSGeo user ID with plugin-author permissions. Register early — approval for a first upload can take a few days.
Packaging is the boundary between “works on my machine” and “installs for everyone”. Treat the structure and metadata as a contract, not decoration.
The Plugin Package Contract
QGIS discovers plugins by scanning its plugin directories for subfolders that contain both an __init__.py and a metadata.txt. Each such folder is one plugin, and its folder name is the plugin’s package name — the identifier QGIS uses everywhere internally. The structure below travels from your source tree, into a zip, up to the repository, and back down to a user’s Plugin Manager.
Three things happen at load time. First, QGIS reads metadata.txt — before any Python is executed — to learn the plugin’s display name, description, version, and qgisMinimumVersion. If the running QGIS is older than that minimum, the plugin is listed but cannot be enabled. Second, QGIS imports the package and calls the module-level classFactory(iface) function in __init__.py, which returns your plugin instance. Third, your initGui() builds the toolbar and menu entries; the lifecycle from there is covered in Plugin Lifecycle and Resource Management.
Resource compilation is what turns loose design assets into importable Python. A Qt .qrc file is an XML manifest listing icons and other binary assets. Running pyrcc5 resources.qrc -o resources.py embeds those assets as byte arrays inside resources.py, so import resources registers them with Qt’s virtual resource system and a path like :/plugins/my_plugin/icon.png resolves at runtime with no external file. Ship the compiled resources.py; the .qrc and raw icons are build-time inputs, not runtime dependencies.
Version discovery is equally mechanical. The Plugin Manager compares the version string in the repository’s metadata against the version recorded in the user’s installed copy. If the repository version is higher, an update badge appears. The comparison is done field by field on dotted numeric components, which is exactly why disciplined semantic versioning — MAJOR.MINOR.PATCH — matters: 1.10.0 must sort above 1.9.0, and it only does if you never zero-pad or reuse a number.
Step-by-Step Implementation
Step 1 — Assemble the Package Directory
Lay out the runtime files under a single package folder whose name is a valid Python identifier. Keep tests, docs, and build scripts outside this folder or explicitly excluded from the zip.
my_plugin/
├─ __init__.py # defines classFactory(iface)
├─ metadata.txt # read by QGIS at load time
├─ plugin_main.py # your plugin class: initGui / unload
├─ resources.py # compiled from resources.qrc
├─ resources.qrc # build-time input (excluded from zip)
├─ ui/
│ └─ dialog.ui # Qt Designer form
└─ i18n/
└─ my_plugin_de.qm # compiled translations
The __init__.py entry point is the contract QGIS relies on:
"""Package entry point QGIS calls when loading the plugin."""
from qgis.gui import QgisInterface
def classFactory(iface: QgisInterface) -> "MyPlugin":
"""Return the plugin instance. QGIS calls this once at load time.
Args:
iface: The live QGIS interface handle, injected by QGIS.
Returns:
An instantiated plugin object exposing initGui() and unload().
"""
from .plugin_main import MyPlugin
return MyPlugin(iface)
Step 2 — Write metadata.txt
metadata.txt is a plain INI file under a single [general] section. QGIS parses it before importing your code, so a typo here breaks the plugin without a Python traceback. The fields below are the core set; the exhaustive field-by-field reference lives in Structuring plugin metadata.txt for Distribution.
[general]
name=My Plugin
qgisMinimumVersion=3.28
description=Batch-processes parcel geometries and exports validated GeoPackages.
about=Provides a toolbar action and Processing algorithm to validate, buffer,
and export parcel layers. Requires no external services.
version=1.2.0
author=Jane Cartographer
email=jane@example.org
# Optional but strongly recommended
repository=https://github.com/janecarto/my_plugin
tracker=https://github.com/janecarto/my_plugin/issues
homepage=https://github.com/janecarto/my_plugin
tags=vector,processing,parcels,validation
category=Vector
icon=icon.png
experimental=False
deprecated=False
Note that version is a string, not a number — 1.2.0, never 1.2. The qgisMinimumVersion should reflect the oldest QGIS you actually test against; setting it too low invites bug reports from unsupported releases. Multi-line values (like about) are continued by indenting subsequent lines.
Step 3 — Compile Qt Resources
Compile the .qrc manifest into importable Python. Do this every time an icon or embedded asset changes, and commit the generated resources.py so the packaged plugin is self-contained.
# Compile Qt resources into an importable module
pyrcc5 my_plugin/resources.qrc -o my_plugin/resources.py
# Compile translation sources (.ts) into runtime catalogues (.qm), if present
lrelease my_plugin/i18n/my_plugin_de.ts -qm my_plugin/i18n/my_plugin_de.qm
If pyrcc5 is missing, install the PyQt5 development tools shipped with your QGIS distribution, or on Linux the pyqt5-dev-tools system package.
Step 4 — Build the Distributable Zip
The archive must contain exactly one top-level folder whose name equals the package name QGIS will import. Build it from the parent directory so the folder is preserved as the zip root, and exclude everything QGIS never runs.
# Build a clean distributable zip from the plugin's parent directory
cd /path/to/project
zip -r my_plugin.zip my_plugin \
-x "my_plugin/resources.qrc" \
-x "my_plugin/i18n/*.ts" \
-x "*/__pycache__/*" \
-x "*.pyc" \
-x "my_plugin/tests/*" \
-x "*/.git/*"
For reproducible builds, a small Python script gives you tighter control over what is included and lets you fail fast if a required file is missing:
"""Build a QGIS-plugin zip with a validated file manifest."""
from __future__ import annotations
import zipfile
from pathlib import Path
REQUIRED = ("__init__.py", "metadata.txt", "resources.py")
def build_plugin_zip(pkg_dir: Path, output: Path) -> Path:
"""Zip a plugin package with the folder preserved as the archive root.
Args:
pkg_dir: Path to the plugin package folder (e.g. ./my_plugin).
output: Destination path for the .zip archive.
Returns:
The path to the written zip archive.
Raises:
FileNotFoundError: If a required top-level file is missing.
"""
for name in REQUIRED:
if not (pkg_dir / name).is_file():
raise FileNotFoundError(f"Missing required file: {pkg_dir / name}")
skip_suffixes = {".pyc", ".ts", ".qrc"}
skip_parts = {"__pycache__", ".git", "tests"}
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as zf:
for path in sorted(pkg_dir.rglob("*")):
if path.is_dir():
continue
if path.suffix in skip_suffixes or skip_parts & set(path.parts):
continue
# arcname keeps pkg_dir.name as the single zip root folder
zf.write(path, path.relative_to(pkg_dir.parent))
return output
Step 5 — Validate the Install
Test the archive the way a user will receive it, not from your source tree. Launch QGIS with a clean profile (qgis --profile packaging-test), then go to Plugins, Manage and Install Plugins, Install from ZIP, and select my_plugin.zip. Confirm the plugin appears in the Installed list, enables without a Python error dialog, and that its icon renders (a blank icon means resources.py was not compiled or not imported). Open the Python Console and check the log for import warnings before you consider the build done.
Advanced Patterns
metadata.txt Fields in Depth
The five mandatory fields — name, qgisMinimumVersion, description, version, author (with email) — are only the floor. Fields like qgisMaximumVersion cap compatibility when you know a future API break is coming; supportsQt6 declares Qt 6 readiness for QGIS builds that use it; plugin_dependencies lets the Plugin Manager pull in other required plugins; and changelog surfaces release notes directly in the Manager UI. Tags and category drive discoverability in repository search. Each field has parsing rules — booleans are True/False, versions are dotted strings, and multi-line text must be indent-continued — that are easy to get subtly wrong. The complete field catalogue, with the exact accepted values and the common parse failures, is documented in Structuring plugin metadata.txt for Distribution.
Publishing Workflow
Once the zip validates locally, distribution runs through the official repository. You upload the archive to plugins.qgis.org with your OSGeo account; the repository validates the metadata, extracts the version, and — for a first submission — routes it to manual review by the plugin approval team. Subsequent versions of an already-approved plugin publish immediately. The repository also serves the update feed that every user’s Plugin Manager polls, which is why the version string in the uploaded metadata is the single source of truth for update notifications. The full account setup, upload steps, approval expectations, and how to handle a rejected submission are covered in Publishing a Plugin to the QGIS Plugin Repository.
Automating Packaging in CI
Manual builds drift — someone forgets to recompile resources.py, or zips from the wrong directory. Moving the compile-and-zip steps into a pipeline makes every release reproducible and lets you attach the artefact to a tagged release automatically. A typical job compiles resources, runs the test suite headlessly, builds the zip with the validated manifest above, and — on a version tag — uploads it to the repository via the repository’s HTTP upload endpoint. Wiring this into your existing pipeline is described in Continuous Integration for QGIS Projects, which pairs naturally with the GitHub Actions test runners in that guide so a release is only cut when tests pass.
Pitfalls and Debugging
-
Wrong zip root folder name: The archive must contain exactly one top-level folder, and its name must be the Python package name QGIS imports. Zipping the contents of
my_plugin/(sometadata.txtsits at the zip root with no folder) or zipping from inside the folder produces an archive the Plugin Manager either rejects or installs to a wrong path. Always build from the parent directory and verify withunzip -l my_plugin.zipthat the first path component ismy_plugin/. -
qgisMinimumVersion mismatch: If
qgisMinimumVersionis higher than the user’s QGIS, the plugin is greyed out and cannot be enabled — with no obvious explanation in the UI. If it is set lower than what your code actually needs, users on older releases install it and hit runtimeAttributeErrororImportErroron APIs that do not exist yet. Set it to the oldest version your tests actually cover, and guard newer APIs withQgis.QGIS_VERSION_INTchecks. -
Uncompiled resources: Shipping the
.qrcwithout the generatedresources.py, or forgetting to recompile after adding an icon, produces blank toolbar buttons and':/plugins/...' not foundwarnings.resources.pyis a build artefact that must be regenerated withpyrcc5whenever the.qrcchanges and included in the zip. -
Forgetting the version bump: The repository rejects an upload whose
versionalready exists, and users never see an update for a version string that has not increased. Every distributed change needs a new, higherversion. A pre-upload check — comparing the metadata version against the last published one — catches this before you waste a round trip. -
Leaving experimental=True in a stable release: With
experimental=True, the Plugin Manager hides the version from users who have not opted into experimental plugins, so your “release” is effectively invisible to most people. Useexperimental=Truedeliberately during a test cycle and flip it toFalsefor the public release. -
Bundling development cruft:
__pycache__folders,.pycfiles, local test fixtures, and.gitmetadata inflate the archive and can leak paths or secrets. Exclude them explicitly in your build step; the manifest-driven Python builder above rejects them by construction.
Conclusion
Packaging is where a working plugin becomes a distributable product. The mechanics are unforgiving but finite: one package folder with the required files, a metadata.txt QGIS can parse before it runs a line of your code, compiled resources.py so assets resolve at runtime, a zip whose single root folder matches the package name, and a semantic version that only ever increases. Validate every build by installing the zip in a clean profile, automate the compile-and-zip steps so releases stay reproducible, and treat the version string as the contract that drives every user’s update notification. With the structure and metadata correct, publishing to the official repository is the short final step rather than a source of silent failures.
Related
- Headless Automation, CI/CD & Testing for PyQGIS — parent guide covering standalone execution, containerisation, testing, and release automation
- Structuring plugin metadata.txt for Distribution — the complete metadata field reference and its parsing rules
- Publishing a Plugin to the QGIS Plugin Repository — account setup, upload, and the approval workflow
- Continuous Integration for QGIS Projects — building and releasing plugin artefacts from a pipeline
- Plugin Lifecycle and Resource Management — how QGIS loads, initialises, and unloads the package you have just built