Publishing to the QGIS Plugin Repository
Writing a working plugin is only half the job. To reach the hundreds of thousands of QGIS users who install extensions through Plugins → Manage and Install Plugins, you have to package your code into a compliant ZIP archive, declare accurate metadata, and pass the automated and human review on the official repository at plugins.qgis.org. This cluster sits within QGIS Plugin Development and walks through the entire release pipeline: building a clean archive, filling out metadata.txt, choosing version numbers, declaring QGIS compatibility, creating a publisher account, and shipping updates without breaking existing installs.
Publishing is a one-way door in the sense that a released version is downloaded and cached by users immediately. A malformed archive or a mistaken qgisMinimumVersion reaches real installations within minutes of approval, so the discipline you apply here directly determines how much support churn you face later.
Prerequisites
- A functional plugin that loads cleanly in a fresh QGIS profile (see Plugin Boilerplate & Structure).
- QGIS 3.34 LTR or newer installed for validation testing (bundles Python 3.12).
- A GPL-compatible license file in the plugin root (GPLv2+ or GPLv3).
- An OSGeo userid (account on plugins.qgis.org) for uploading.
- Command-line access to
zip, plus optionallypb_toolorpaverfor repeatable builds.
The Release Pipeline
Every successful release follows the same five stages. Treat each stage as a gate: do not advance until the previous one is clean.
Building a Clean Source Tree
Before you can package anything, the source tree has to contain only what QGIS will run. Two artifacts commonly need compilation: Qt resource files (.qrc → resources_rc.py) and, if you use static UI compilation, .ui files. Most modern plugins skip static UI compilation and load .ui files dynamically with uic.loadUiType(), so resources are usually the only build step.
# From the plugin root, compile the Qt resource file
pyrcc5 resources.qrc -o resources_rc.py
# Optional: compile a translation if you ship .ts files
lrelease i18n/myplugin_de.ts
Breakdown: pyrcc5 bundles icons referenced in resources.qrc into an importable Python module. If your metadata.txt points icon= at a real PNG on disk instead of a :/ resource path, you can skip .qrc entirely. lrelease turns human-edited .ts translation sources into binary .qm files that QGIS loads at runtime; never ship .ts without the compiled .qm.
A repeatable build is worth automating early. The pb_tool utility reads a pb_tool.cfg manifest and handles compilation, deployment, and zipping in one command. This is the same toolchain discussed in Testing & CI for QGIS Plugins, where the build step also feeds your continuous integration pipeline.
pip install pb_tool
pb_tool compile # builds resources and UI
pb_tool zip # produces a repository-ready archive
Breakdown: pb_tool zip reads the [files] section of pb_tool.cfg to decide exactly which files enter the archive, which is far safer than zipping a directory by hand and accidentally including __pycache__, .git, or local test data.
A typical pb_tool.cfg declares the plugin name, the Python sources, the extra assets, and the compiled artifacts explicitly:
[plugin]
name: field_tools
package_name: field_tools
[files]
python_files: __init__.py field_tools.py logic.py
main_dialog: ui/main_dialog.ui
compiled_ui_files:
resource_files: resources.qrc
extras: metadata.txt icons LICENSE
locales:
Breakdown: Listing sources by name means the archive is a deliberate manifest rather than an accident of whatever sits in your working directory. The extras line pulls in metadata.txt, the icon folder, and the license; resource_files triggers pyrcc5 during pb_tool compile. Keeping this file under version control gives every release an identical, reproducible build regardless of which machine runs it.
Packaging the ZIP Correctly
The repository imposes one strict structural rule: the ZIP must contain a single top-level folder whose name matches the plugin's package name, and metadata.txt must live directly inside that folder. QGIS uses the folder name as the Python package name when it imports your plugin, so it must be a valid identifier (lowercase, underscores, no spaces or hyphens).
field_tools.zip
└── field_tools/
├── __init__.py
├── metadata.txt
├── field_tools.py
├── resources_rc.py
├── ui/
│ └── main_dialog.ui
├── icons/
│ └── icon.png
└── LICENSE
If you zip from the command line, archive the parent of the plugin folder so the folder itself becomes the top-level entry:
# Standing one level above the plugin folder
zip -r field_tools.zip field_tools \
-x "field_tools/__pycache__/*" \
-x "field_tools/.git/*" \
-x "field_tools/tests/*" \
-x "*.pyc"
Breakdown: The -x exclusions strip development artifacts. Bytecode caches (__pycache__, *.pyc) bloat the archive and can ship stale code; tests/ is useful in your repo but should not run in users' QGIS installs. The repository rejects archives where metadata.txt is at the root rather than inside a single folder, and it rejects folder names containing dashes.
Verifying the Archive Before Upload
The cheapest bug to fix is the one you catch before uploading. Install your freshly built ZIP into a clean QGIS profile exactly as a user would, and confirm a full load/unload cycle leaves no traceback. A throwaway profile guarantees you are not relying on something already present in your working setup.
# Launch QGIS with a disposable profile so nothing is pre-installed
qgis --profile release_test
# In the GUI: Plugins → Manage and Install Plugins → Install from ZIP
You can also script a sanity check on the archive contents so packaging mistakes surface in CI rather than in front of a user:
import zipfile
ARCHIVE = "field_tools.zip"
with zipfile.ZipFile(ARCHIVE) as zf:
names = zf.namelist()
# Exactly one top-level folder, metadata.txt directly inside it
tops = {n.split("/")[0] for n in names}
assert len(tops) == 1, f"archive must have one top folder, found {tops}"
root = tops.pop()
assert f"{root}/metadata.txt" in names, "metadata.txt not at folder root"
# No development junk leaked in
leaked = [n for n in names if "__pycache__" in n or n.endswith(".pyc")
or "/.git/" in n]
assert not leaked, f"remove dev artifacts: {leaked}"
print(f"{ARCHIVE} structure OK ({root})")
Breakdown: The script enforces the repository's two structural rules — a single top-level folder and metadata.txt directly inside it — plus the cleanliness rules, by inspecting the ZIP's name list without unpacking it. Running this as a CI step means a malformed archive fails the build rather than the upload, mirroring the philosophy of the Testing & CI for QGIS Plugins pipeline.
Filling Out metadata.txt
The repository parses metadata.txt to populate the plugin listing, enforce version compatibility, and render the changelog. A handful of fields are mandatory; the rest improve discoverability and trust. The minimum required keys are name, qgisMinimumVersion, description, version, author, email, and about.
[general]
name=Field Tools
qgisMinimumVersion=3.34
qgisMaximumVersion=3.99
description=Streamlines attribute editing for field survey workflows.
version=1.2.0
author=Jane Cartographer
email=jane@example.com
about=Field Tools adds bulk attribute editing, quick CRS checks, and
survey export presets to speed up post-fieldwork data cleaning.
tracker=https://github.com/jane/field-tools/issues
repository=https://github.com/jane/field-tools
homepage=https://github.com/jane/field-tools
category=Vector
tags=attributes,editing,field,survey
icon=icons/icon.png
experimental=False
deprecated=False
changelog=1.2.0 - Add CRS quick-check panel
1.1.0 - Bulk attribute editor
1.0.0 - Initial release
Breakdown: Multi-line values (about, changelog) continue onto indented lines. qgisMaximumVersion is optional but recommended to set generously (e.g. 3.99) so the manager does not hide your plugin from users on newer releases. The icon= path is relative to the plugin folder; if you compiled resources you can instead use a Qt resource path such as icon=:/plugins/field_tools/icon.png. For an exhaustive, annotated reference to every key and its validation rules, see Write metadata.txt for a QGIS Plugin.
Semantic Versioning and the Changelog
The repository treats version as a sortable string and uses it to decide whether a user's installed copy is outdated. Adopt semantic versioning — MAJOR.MINOR.PATCH — so the meaning of each release is unambiguous:
- MAJOR — incompatible changes: removed features, changed settings keys, dropped support for an older QGIS.
- MINOR — new, backward-compatible functionality.
- PATCH — backward-compatible bug fixes only.
# A tiny helper to keep metadata.txt and your code in sync at release time
import re
from pathlib import Path
def bump_patch(metadata_path):
"""Increment the PATCH component in metadata.txt and return the new version."""
text = Path(metadata_path).read_text(encoding="utf-8")
match = re.search(r"^version=(\d+)\.(\d+)\.(\d+)$", text, re.MULTILINE)
if not match:
raise ValueError("version= must be MAJOR.MINOR.PATCH")
major, minor, patch = (int(g) for g in match.groups())
new_version = f"{major}.{minor}.{patch + 1}"
text = re.sub(r"^version=.*$", f"version={new_version}",
text, count=1, flags=re.MULTILINE)
Path(metadata_path).write_text(text, encoding="utf-8")
return new_version
if __name__ == "__main__":
print("Released", bump_patch("field_tools/metadata.txt"))
Breakdown: This script parses the version= line, increments the patch number, and writes it back. Automating the bump prevents the classic mistake of uploading a ZIP whose version matches an already-published release, which the repository rejects. Wire a longer version of this into the release job described in Testing & CI for QGIS Plugins.
The changelog field is rendered verbatim on your plugin page and shown to users when an update is available. Keep the newest version at the top, one entry per line, and describe user-visible changes rather than internal refactors.
QGIS Compatibility Flags
qgisMinimumVersion and qgisMaximumVersion are the contract between your plugin and the user's QGIS. The plugin manager hides any plugin whose required range does not include the running version. Set the minimum to the oldest QGIS you actually test against, not the oldest you hope works.
# Guard against API differences at runtime when you support a version range
from qgis.core import Qgis
if Qgis.versionInt() >= 33400: # 3.34.x and newer
from qgis.core import QgsVectorFileWriter
save_options = QgsVectorFileWriter.SaveVectorOptions()
# use writeAsVectorFormatV3 on modern builds
else:
# fall back to the older signature on 3.28 LTR
pass
Breakdown: Qgis.versionInt() returns an integer like 33400 for 3.34.0, which is easy to compare. Use it to branch on API changes when your declared range spans both the 3.28 LTR (Python 3.9) and 3.34 LTR (Python 3.12) lines. If you cannot or do not want to support older builds, simply raise qgisMinimumVersion instead of writing compatibility shims.
There is a real cost to setting the minimum too low. Every version you declare support for is a version someone will install you on and file bugs against. Declaring qgisMinimumVersion=3.16 when you have only ever run the plugin on 3.34 is a promise you cannot keep. The honest figure is the oldest QGIS in your CI matrix — the testing workflow in Testing & CI for QGIS Plugins exists precisely so this number reflects reality rather than hope. When you do drop an old version, raise the minimum and add a changelog line announcing it, because users on that build will silently stop receiving updates.
The Experimental and Deprecated Flags
Two boolean flags change how the repository surfaces your plugin. experimental=True hides the version from users unless they tick Show also experimental plugins in the manager settings — use it for alpha-quality releases or risky new versions of an established plugin. deprecated=True marks the entire plugin as unmaintained and discourages new installs.
A practical pattern is to publish a new major rewrite as experimental=True first, gather feedback, then flip it to False once stable. The flag lives per-version, so you can have a stable 1.x line and an experimental 2.0.0 rewrite available simultaneously — cautious users stay on stable while early adopters opt into the new build.
deprecated=True is the graceful way to retire a plugin you no longer maintain. It does not delete anything; existing installs keep working, but the manager discourages new ones and your plugin page shows a deprecation banner. If a replacement exists, name it in the about text so users know where to migrate. Reserve deprecation for a genuine end-of-life rather than a temporary pause in development.
Creating an Account and Uploading
- Register an OSGeo userid at plugins.qgis.org (the same credentials work across OSGeo services).
- Sign in and choose Plugins → Upload a plugin.
- Select your ZIP. The site parses
metadata.txtimmediately and reports any structural or field errors before accepting the file. - On first upload, the plugin enters the queue as unapproved. A repository maintainer reviews it for licensing, security (no obfuscated code, no bundled binaries phoning home), and basic functionality.
- Once approved, you become the plugin owner and can grant additional maintainers, manage version visibility, and upload future releases without re-review (subsequent versions of an already-approved plugin go live automatically unless flagged).
The first review is the slowest gate. You can shorten it by shipping clean, readable code, a clear about, a working tracker URL, and a recognized open-source license file.
What reviewers actually look for is predictable, and pre-empting it saves a round trip:
- No obfuscated or minified Python. The repository hosts source, not compiled blobs; code reviewers must be able to read what they approve.
- No bundled binaries that execute or download code at runtime. Pure-Python dependencies vendored into the plugin folder are acceptable; shipping a compiled
.so/.dllor fetching executables on first run is not. - A working tracker and repository URL. Dead links signal an unmaintained submission and stall approval.
- A license file present in the root, matching what you declare. A GPL header in source files alongside a
LICENSEfile is the conventional, fast-to-approve combination. - Sensible network behavior. If the plugin contacts a remote service, the
abouttext should say so plainly.
If a reviewer requests changes, you fix them, bump the version, and re-upload to the same plugin page rather than creating a second listing.
Releasing Updates
For an established plugin, releasing is mechanical: bump version, prepend a changelog entry, rebuild the ZIP, and upload it to the same plugin page. The repository validates that the new version is strictly greater than the latest published one. Users see an Upgradeable badge in their plugin manager on next refresh.
A safe update checklist:
- Bump
versionper semantic versioning and confirm it is higher than the live release. - Add a
changelogline describing the change. - If you changed the minimum QGIS, update
qgisMinimumVersionand announce it — raising it strands users on older builds. - Reinstall the freshly built ZIP into a clean profile and confirm a full load/unload cycle, exactly as you would when validating Plugin Boilerplate & Structure.
- If the update carries risk, publish it as
experimental=Truefirst.
Compatibility Notes
| QGIS line | Bundled Python | Recommended qgisMinimumVersion | Notes |
|---|---|---|---|
| 3.28 LTR | 3.9 | 3.28 | Older QgsVectorFileWriter signatures; avoid f-string-heavy syntax only if you also target 3.6 |
| 3.34 LTR | 3.12 | 3.34 | Baseline for this guide; modern Processing and writer APIs available |
| 3.40 / 3.44 | 3.12 | 3.34 with qgisMaximumVersion=3.99 | Set a high maximum so the manager keeps showing your plugin |
Pin examples and your CI matrix to 3.34 LTR, and only declare support for an older or newer line after you have actually loaded the plugin there.
Key Takeaways
- The ZIP must contain exactly one top-level folder, named as a valid Python package, with
metadata.txtinside it. - Required metadata keys are
name,qgisMinimumVersion,description,version,author,email, andabout. - Use semantic versioning; the repository rejects a
versionthat is not strictly greater than the live one. - Set
qgisMinimumVersionto what you test andqgisMaximumVersiongenerously so newer QGIS users still see your plugin. experimentalanddeprecatedare per-version flags that control visibility and discourage installs respectively.- First upload requires human review; later versions of an approved plugin publish automatically.
Frequently Asked Questions
Do I need to compile resources before publishing?
Only if your plugin references icons or assets through Qt resource paths (:/...). If metadata.txt and your code point at real files on disk such as icons/icon.png, you can skip pyrcc5 entirely. Always ship compiled .qm translations if you ship any translations.
Why was my upload rejected with "metadata.txt not found"?
The archive almost certainly has metadata.txt at the ZIP root or inside a wrongly named or nested folder. The file must sit directly inside a single top-level folder whose name is a valid Python package identifier. Re-zip from the parent directory of the plugin folder.
Can I delete a published version? You can hide or set a version as experimental, but you generally cannot scrub a release that users have already downloaded. The correct fix for a bad release is to publish a higher patch version immediately rather than trying to delete the broken one.
How long does first approval take? It depends on maintainer availability and the cleanliness of your submission, ranging from a day to a couple of weeks. Clean code, a clear license, and a working tracker URL speed it up considerably.
What license must my plugin use?
The repository requires a GPL-compatible license because QGIS itself is GPL and plugins link against its API. GPLv2+ or GPLv3 are the typical choices. Include the full license text as a LICENSE file in the plugin root.