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.
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. lreleaseif the plugin ships translations, andpyrcc5if 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.
Test the archive before uploading
Install the zip you built, in a profile that has none of your development state:
- Launch QGIS with a fresh profile:
qgis --profile release_test. - Plugins → Manage and Install Plugins → Install from ZIP, and choose the built archive.
- Enable it, run its main action, open its options page, and check the Python error log.
- 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 version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | Same archive format; test against the oldest version your qgisMinimumVersion claims. |
| 3.28 LTR | 3.9 | Identical. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Identical; 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.txtis missing from it. Open the zip and look. - It installs but does not appear. The folder name does not match the package, or
__init__.pyhas noclassFactory. - It works from the folder but not from the zip. A file was excluded that is actually needed — usually a
.qm, a.uior the compiledresources.py. - The upload is rejected for size. Test data or a
.gitfolder 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.txtdisagree. 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.