Package a QGIS Plugin as a Zip

The QGIS plugin format is a zip file, which sounds like it needs no explanation until the upload is rejected for the third time. The rules are simple and unforgiving: one top-level folder inside the archive, named exactly as the plugin's package, containing a valid metadata.txt, with no development detritus and no compiled Python. Getting them right by hand works once; getting them right on every release needs a build script.

This recipe belongs to Publishing to the QGIS Plugin Repository. It covers the required structure, what to include and leave out, compiling resources and translations before packaging, and a repeatable build that produces a clean archive every time.

The one structural ruleA valid plugin archive contains exactly one top level folder, named as the plugin package, holding the init file, metadata and everything else. An archive whose files sit at the root of the zip, or which contains a wrapper folder with a different name, is rejected by the installer or installs under a name that does not match the package.One folder in, named exactly as the packageparcel tools.zip — correctparcel tools/init.py — classFactory lives heremetadata.txti18n/, icon.png, the modulesinstalls and loadsflat zip — rejectedinit.pymetadata.txtthe modules, at the rootno package folder — no pluginzip the folder, not its contents

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer for testing the result.
  • A working plugin with a valid metadata.txt — see Write metadata.txt for a QGIS Plugin.
  • lrelease if the plugin ships translations, and pyrcc5 if it uses a compiled resources file.

What goes in, and what does not

Include: every Python module the plugin imports, metadata.txt, __init__.py with classFactory, .ui files, icons and images, compiled .qm translation files, a LICENSE, and a short README.

Exclude: __pycache__ folders and .pyc files, .git, tests and test data, .ts translation sources, development configuration such as .vscode or .idea, and anything large the plugin does not need at run time. Sample datasets are the usual offender — a plugin should be a few hundred kilobytes, and a fifty-megabyte upload is nearly always a folder of test data somebody forgot.

Two exclusions matter more than tidiness. Compiled .pyc files from a different Python version can shadow the real source and produce failures that make no sense. And a .git folder in a public archive frequently contains history somebody did not intend to publish.

Compile what needs compiling

Two artefacts must be built before packaging, and both are easy to forget because the plugin works without them in development.

# translations: .ts sources become .qm binaries
lrelease i18n/parcel_tools_de.ts i18n/parcel_tools_fr.ts

# resources, if the plugin uses a .qrc file
pyrcc5 -o resources.py resources.qrc

Breakdown: lrelease produces the .qm files the plugin loads at run time; ship those and leave the .ts sources out. Skipping this step is the single most common reason a fully translated plugin appears in English for every user — the developer's machine has the .qm files from an earlier build, and the archive does not. pyrcc5 compiles a Qt resource file into an importable Python module, which is how icons are referenced as :/plugins/parcel_tools/icon.png; the generated resources.py must be in the archive, while the .qrc need not be. Neither step is run by the zip command, so both belong in the build script.

A repeatable build script

#!/usr/bin/env python3
"""Build an installable plugin archive."""
import shutil
import subprocess
import zipfile
from pathlib import Path

PACKAGE = "parcel_tools"
SOURCE = Path(__file__).parent / PACKAGE
BUILD = Path(__file__).parent / "build"

EXCLUDE_DIRS = {"__pycache__", ".git", "tests", ".idea", ".vscode"}
EXCLUDE_SUFFIXES = {".pyc", ".pyo", ".ts", ".qrc"}


def read_version():
    for line in (SOURCE / "metadata.txt").read_text().splitlines():
        if line.startswith("version="):
            return line.split("=", 1)[1].strip()
    raise RuntimeError("no version in metadata.txt")


def build():
    subprocess.check_call(["lrelease"] + [str(p) for p in SOURCE.glob("i18n/*.ts")])

    if BUILD.exists():
        shutil.rmtree(BUILD)
    BUILD.mkdir()

    archive = BUILD / f"{PACKAGE}-{read_version()}.zip"
    with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
        for path in sorted(SOURCE.rglob("*")):
            if any(part in EXCLUDE_DIRS for part in path.parts):
                continue
            if path.suffix in EXCLUDE_SUFFIXES or not path.is_file():
                continue
            zf.write(path, path.relative_to(SOURCE.parent))

    print(f"built {archive}")


if __name__ == "__main__":
    build()

Breakdown: Writing each file with a path relative to the parent of the source folder is what puts everything inside a top-level parcel_tools/ directory in the archive — relative to the source folder itself would produce the flat zip the installer rejects. Reading the version out of metadata.txt means the file name always matches what the plugin reports, so nobody has to guess which of three zips on the desktop is current. Clearing the build folder first prevents a stale archive being mistaken for a fresh one. Sorting the paths makes the archive byte-comparable between runs, which is quietly useful when checking that a release contains only the changes you expect.

What goes in the archiveThe archive should contain the Python modules, metadata, user interface files, icons, compiled translations and a licence. It should exclude compiled Python caches, the version control folder, tests and test data, translation sources and editor configuration. Test data and a version control folder are the two exclusions that account for almost every oversized upload.A plugin should be a few hundred kilobytesincludeevery imported modulemetadata.txt and init.py.ui files, icons, resources.pycompiled .qm translationsa LICENSE and a READMEeverything needed at run timeexcludepycache folders and .pycthe .git foldertests and test data.ts translation sourceseditor configurationthe bold two explain most oversized uploads

The release sequence, with the test that catches most mistakesA release compiles translations and resources, builds the archive excluding development files, then installs that archive into a clean QGIS profile to confirm it loads. Only after the clean-profile test does the archive go to the repository. The test is what catches missing files, since the developer's own profile already has them.Test the archive, not the folder you develop incompiletranslations, resourcesbuild the zipexcluding dev filesinstall in a clean profilethe step people skipuploadtag the release tooqgis --profile release_testa profile with none of your development leftovers in it

Test the archive before uploading

Install the zip you built, in a profile that has none of your development state:

  1. Launch QGIS with a fresh profile: qgis --profile release_test.
  2. Plugins → Manage and Install Plugins → Install from ZIP, and choose the built archive.
  3. Enable it, run its main action, open its options page, and check the Python error log.
  4. Switch the interface language to one you ship a translation for, restart, and confirm the strings changed.

Breakdown: The clean profile is what makes this a real test. In your development profile the plugin folder already exists, the translations are already compiled, and any file you forgot to include is still there on disk — so the archive appears to work while being incomplete. This four-step check takes two minutes and catches the great majority of "it worked for me" release failures. The same reasoning applies to a continuous-integration job that builds the archive and installs it in a container, which the setup in Run QGIS Plugin Tests in GitHub Actions already provides most of.

Keep the build honest

Three habits keep releases boring, which is what you want from them.

Build from a clean checkout. A build script run in your working directory can pick up files you have not committed, producing an archive that cannot be reproduced from the repository. Build from a fresh clone, or from an export, and the archive and the tag always agree.

Never edit the archive. Fixing a file inside the zip after building it produces a release nobody can rebuild. Fix the source, bump the version, build again.

Automate it on tag. A workflow that builds and attaches the archive whenever a version tag is pushed removes the last opportunity for a hand-made mistake, and gives every release a downloadable artefact with a matching tag — the versioning discipline in Version and Changelog a QGIS Plugin assumes exactly this.

QGIS version compatibility

QGIS versionPythonNotes
3.22 LTR3.9Same archive format; test against the oldest version your qgisMinimumVersion claims.
3.28 LTR3.9Identical.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Identical; Install from ZIP unchanged.

One archive serves every QGIS version it declares support for, so the compatibility work is in metadata.txt and in your code, not in the packaging.

Troubleshooting

  • The installer says the plugin is not valid. No single top-level folder, or metadata.txt is missing from it. Open the zip and look.
  • It installs but does not appear. The folder name does not match the package, or __init__.py has no classFactory.
  • It works from the folder but not from the zip. A file was excluded that is actually needed — usually a .qm, a .ui or the compiled resources.py.
  • The upload is rejected for size. Test data or a .git folder got in. Check what the archive actually contains.
  • Icons are missing after install. The resources file was not compiled, or the compiled module was excluded.
  • The wrong version installs. The archive name and the version in metadata.txt disagree. Read the version from the file, as the script does.

Conclusion

A plugin archive is a zip containing exactly one folder named as the package, holding __init__.py, metadata.txt and everything the plugin needs at run time — with development files, compiled Python and translation sources left out. Compile translations and resources first, build with a script that derives the version from the metadata, and install the result into a clean profile before uploading. Automate it on a tag and releases stop being an event.

Frequently Asked Questions

Can I just zip the folder from my file manager? Yes, if you zip the folder itself rather than its contents and have already removed __pycache__ and friends. A script is worth it by the second release.

Does the zip name matter? Not to the installer, which reads the folder name and the metadata. It matters to humans, so include the plugin name and the version.

Should tests be in the archive? No. They add weight and are irrelevant at run time. Keep them in the repository.

How do I ship a plugin that needs a third-party library? Declare it in metadata.txt and fail with a clear message when it is missing. Vendoring a library into the archive is a last resort and creates conflicts with other plugins.

Can I distribute the zip directly instead of using the repository? Yes — users can install from ZIP, and organisations often host an internal repository. The archive format is the same either way.

Why is my plugin folder named differently after install? QGIS uses the folder name inside the archive. If that does not match the package name your imports use, the plugin will not load.