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.txt fields and Plugin Manager behaviour described here target the 3.28 long-term release and later. Declare your true minimum in qgisMinimumVersion.
  • Qt tooling for resource compilation: pyrcc5 ships with the PyQt5 development tools bundled in most QGIS installations. Confirm it is on your PATH before 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.

QGIS plugin packaging and distribution flow A diagram in four stages. Stage one is a plugin source directory tree with a root package folder holding the required files __init__.py, metadata.txt, plugin_main.py, resources.py, a ui folder and an i18n folder. An arrow leads to a zip archive node. A second arrow leads to the QGIS Plugin Repository node. A third arrow leads to a user installing through the Plugin Manager. Source package folder my_plugin/ ├─ __init__.py ├─ metadata.txt ├─ plugin_main.py ├─ resources.py ├─ ui/ dialog.ui └─ i18n/ *.qm / *.ts zip my_plugin.zip one root folder upload QGIS Plugin Repository plugins.qgis.org install User installs via 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 versioningMAJOR.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.

text
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:

python
"""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.

ini
[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.

bash
# 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.

bash
# 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:

python
"""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/ (so metadata.txt sits 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 with unzip -l my_plugin.zip that the first path component is my_plugin/.

  • qgisMinimumVersion mismatch: If qgisMinimumVersion is 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 runtime AttributeError or ImportError on APIs that do not exist yet. Set it to the oldest version your tests actually cover, and guard newer APIs with Qgis.QGIS_VERSION_INT checks.

  • Uncompiled resources: Shipping the .qrc without the generated resources.py, or forgetting to recompile after adding an icon, produces blank toolbar buttons and ':/plugins/...' not found warnings. resources.py is a build artefact that must be regenerated with pyrcc5 whenever the .qrc changes and included in the zip.

  • Forgetting the version bump: The repository rejects an upload whose version already exists, and users never see an update for a version string that has not increased. Every distributed change needs a new, higher version. 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. Use experimental=True deliberately during a test cycle and flip it to False for the public release.

  • Bundling development cruft: __pycache__ folders, .pyc files, local test fixtures, and .git metadata 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