Bundle Third-Party Dependencies in a QGIS Plugin

Every QGIS plugin on a machine shares one Python interpreter and one sys.modules. That makes dependencies genuinely hard: installing a package into the QGIS Python affects every plugin, two plugins wanting different versions of the same library cannot both be satisfied, and a user with no administrator rights may not be able to install anything at all.

This recipe belongs to Plugin Boilerplate & Structure in PyQGIS. It covers vendoring packages into the plugin, adding the directory to the path safely, the clash that happens when two plugins vendor different versions of the same library, and the cases where requiring rather than bundling is the better answer.

One interpreter, one module cacheTwo plugins each ship their own copy of a library at different versions. Because QGIS runs them in one Python process with one module cache, whichever plugin loads first puts its version into sys.modules and the other plugin silently gets that version instead of its own.Whichever loads first wins — for everybodyplugin A_vendor/shapely 2.0loaded at startupgets 2.0 — correctplugin B_vendor/shapely 1.8loaded secondgets 2.0 — wrongsys.modulesone entry per namethe failureplugin B breakson a machine whereplugin A is installed

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A plugin package with the usual layout — see creating a QGIS plugin with Plugin Builder.
  • A dependency that is genuinely necessary. The first question is always whether QGIS already provides it.

Ask whether you need it at all

QGIS ships an unusually rich Python environment, and a surprising number of dependencies are already there.

numpy is present and used by the raster APIs. requests is not, but QgsNetworkAccessManager and Qt's networking cover most of what it is used for, with the advantage of honouring the user's proxy settings and stored credentials. shapely is not present, but QgsGeometry wraps the same GEOS library. pandas is not present and is genuinely useful, but for many tabular jobs the feature iterator and a dictionary are enough.

import importlib

for name in ("numpy", "requests", "shapely", "pandas", "yaml", "jinja2"):
    spec = importlib.util.find_spec(name)
    print(f"{name:<10} {'present' if spec else 'missing'}")

Breakdown: Running this in the QGIS Python console on the oldest QGIS you intend to support is the check that matters — a package present in 3.40 may be absent in 3.28, and the plugin repository lists a minimum version you will be held to. A dependency that is present everywhere you support needs no bundling and no requirement; one that is present on your machine only is the most dangerous kind, because it works right up until somebody else installs the plugin.

Vendor into a private subdirectory

The safe way to bundle is to put third-party code in a subdirectory of the plugin and import it through a private name.

my_plugin/
├── __init__.py
├── metadata.txt
├── my_plugin.py
└── _vendor/
    ├── __init__.py
    └── tinycss2/
# my_plugin/_vendor/__init__.py
import os
import sys

_HERE = os.path.dirname(__file__)
if _HERE not in sys.path:
    sys.path.insert(0, _HERE)

Breakdown: Inserting at position 0 makes the vendored copy win over anything else on the path — which is what you want for your own imports and precisely the problem for everybody else, because sys.path is global. Appending instead (sys.path.append) is more neighbourly but means an incompatible version installed system-wide wins over yours. Neither is right in general; the choice depends on whether a wrong version breaks you or breaks them.

The genuinely safe pattern avoids touching sys.path at all by importing through the package hierarchy:

from ._vendor import tinycss2

Breakdown: This works when the vendored package has no absolute imports of its own — a pure-Python library with relative imports vendors cleanly this way and never appears in sys.modules under its plain name, so no other plugin can collide with it. A library that does import tinycss2.ast internally will not, and needs either the path manipulation above or a rewrite of its imports, which is what tools like vendoring automate.

Prefer pure Python

A pure-Python dependency vendors into a directory and works everywhere. A compiled one does not.

A package with C extensions is built for one Python version, one platform and one ABI. Bundling shapely means shipping wheels for Windows, macOS on two architectures and several Linux variants, matching the Python version of every QGIS release you support — and then repeating the exercise when QGIS moves from Python 3.9 to 3.12. It is achievable and it is a substantial ongoing commitment.

Where a compiled dependency is unavoidable, the realistic options are to require it rather than bundle it, or to reimplement the small part you need. A plugin needing one geometry predicate is far better served by QgsGeometry than by vendoring GEOS bindings.

Deciding what to do with a dependencyIf the package already ships with QGIS on every supported version, nothing is needed. If it is pure Python, vendor it into a private subdirectory. If it has compiled extensions, requiring it with a clear error message is more sustainable than shipping wheels for every platform and Python version.Compiled dependencies are a maintenance commitmentwhat kind of package?already in QGISnumpy, PyQt, sqlite3on every supported versiondo nothingpure Pythonno compiled extensionsrelative imports throughoutvendor it privatelycompiledwheels per OS, archand Python versionrequire, do not bundle

Require it, gracefully

When a dependency must be installed rather than bundled, failing well is most of the job.

MISSING_MESSAGE = (
    "This plugin needs the '{name}' package, which is not installed in the "
    "QGIS Python environment.\n\n"
    "Install it from the OSGeo4W shell (Windows) or a terminal:\n"
    "    python3 -m pip install --user {name}\n\n"
    "Then restart QGIS."
)


def require(name):
    try:
        return __import__(name)
    except ImportError:
        from qgis.PyQt.QtWidgets import QMessageBox
        QMessageBox.warning(None, "Missing dependency", MISSING_MESSAGE.format(name=name))
        raise

Breakdown: Doing this at the point of use rather than at import time is deliberate: a plugin whose __init__.py raises does not appear in the plugin manager at all, and the user has no way to see why. Deferring the check until the feature that needs it is used means the rest of the plugin still works and the message appears in context. Including the exact command in the message removes the support round trip almost entirely — see installing Python packages into QGIS for the platform variations worth mentioning.

The plugin repository's metadata.txt has no dependency field that QGIS acts on, so there is no mechanism that installs anything for the user. Stating requirements in the description and about fields is the only documentation channel that reaches the plugin manager.

Clean up on unload

Anything added to sys.path should be removed when the plugin unloads, or a reload leaves duplicates.

def unload(self):
    import sys
    vendor = os.path.join(os.path.dirname(__file__), "_vendor")
    if vendor in sys.path:
        sys.path.remove(vendor)
    for name in list(sys.modules):
        if name.startswith("my_plugin"):
            del sys.modules[name]

Breakdown: Removing the plugin's own modules from sys.modules is what lets Plugin Reloader pick up edits to vendored code as well as to your own. Iterating over list(sys.modules) rather than the live dictionary avoids mutating during iteration. Note that this cannot unload a compiled extension — once a C extension is imported it stays for the life of the process, which is another reason to prefer pure Python.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.16 LTR3.7Vendored wheels must target Python 3.7 ABI if compiled.
3.22 LTR3.9Python 3.9 on most platforms; a compiled bundle needs a separate build.
3.28 LTR3.9Same ABI as 3.22 on most distributions.
3.34 LTR3.12Baseline for this page; Python 3.12 ABI on Windows and macOS builds.
3.40+3.12Unchanged ABI, but check before assuming for a compiled dependency.

Troubleshooting

  • The plugin works alone and breaks with another installed. Both vendor the same library at different versions. Import through the package hierarchy rather than sys.path.
  • ImportError for a vendored package. It uses absolute imports internally and needs its directory on the path, or its imports rewritten.
  • The plugin does not appear in the manager. An import at module level raised. Defer dependency checks to the point of use.
  • A compiled wheel fails to load. It was built for a different Python version or platform. Check sys.version_info and the wheel tag.
  • Reloading does not pick up vendored changes. The vendored modules are still in sys.modules. Clear them in unload.
  • The zip is enormous. A vendored package brought its own dependencies and test suite. Vendor only what is imported.

Conclusion

Check whether QGIS already ships it, prefer pure-Python dependencies vendored into a private _vendor package imported through the package hierarchy, and require rather than bundle anything compiled. Fail at the point of use with a message containing the exact install command, and clean up both sys.path and sys.modules on unload.

Frequently Asked Questions

Can a plugin install its own dependencies with pip? Technically yes, and several do. It is poor behaviour: it needs network access, may need administrator rights, modifies a shared environment, and can break other plugins. If you do it, ask first and make it optional.

Is there a supported dependency mechanism? Not in the plugin repository. Some plugins depend on the QPIP plugin, which manages this, but that is itself a dependency the user must have.

Should I vendor QGIS itself? No. The plugin runs inside QGIS by definition; metadata.txt declares the minimum version and the plugin manager enforces it. See writing metadata.txt for a QGIS plugin.

How do I test that the fallback path works? Rename the vendored directory and run the test suite, or set up a CI job with a minimal environment. Testing only on a machine that has everything installed is how a missing-dependency bug reaches users.