Port a QGIS Plugin to QGIS 4 and Qt6
The QGIS 4 series moves the application from Qt5 and PyQt5 to Qt6 and PyQt6. For plugin authors the good news is that the QGIS API itself barely changes between late 3.x and 4.0 — the deprecations that were removed had been flagged for years. The work is almost all on the Qt side: PyQt6 is stricter about enums, drops a handful of legacy names, moves a few classes between modules, and no longer ships the resource compiler many plugins relied on. A plugin written carefully can run unchanged on 3.40 LTR, 3.44 LTR and QGIS 4 from one codebase.
This recipe belongs to QGIS Plugin Boilerplate & Structure. It audits a plugin for Qt5-only code, applies the automated and manual fixes, updates metadata.txt so the plugin repository offers it to QGIS 4 users, and sets up testing against both generations.
Prerequisites
- The plugin's source in version control, on a branch for the port.
- QGIS 3.40 LTR or 3.44 LTR to keep supporting, and a QGIS 4 build to test against. Pick a minimum version of 3.38 or later if you can: it is the first release that accepts every Qt6-compatible spelling used below, including
QMetaTypefield types. - Imports that already go through
qgis.PyQtrather thanPyQt5directly. If they do not, that is the first fix.
Audit the plugin
Before changing anything, find out how much there is to change. A short scan for the Qt5-only patterns gives a realistic size for the job and a checklist to work through.
import re
from pathlib import Path
PATTERNS = {
"direct PyQt5 import": r"\bfrom PyQt5\b|\bimport PyQt5\b",
"exec_()": r"\.exec_\(",
"QRegExp": r"\bQRegExp\b",
"QVariant field type": r"QVariant\.(String|Int|Double|LongLong|Bool|Date|DateTime)\b",
"unscoped Qt enum": r"\bQt\.(Align\w+|Checked|Unchecked|UserRole|DisplayRole|LeftButton|RightButton|Key_\w+)\b",
"unscoped dialog button": r"\bQ(MessageBox|DialogButtonBox)\.(Yes|No|Ok|Cancel|Save|Close)\b",
"QAction from QtWidgets": r"QtWidgets import[^\n]*\bQAction\b",
"compiled resources": r"\bresources_rc\b|\bimport resources\b|:/plugins/",
"QDesktopWidget": r"\bQDesktopWidget\b",
"deprecated QgsWkbTypes enum": r"QgsWkbTypes\.(Point|LineString|Polygon|Multi\w+)\b",
}
root = Path("/home/dev/plugins/asset_tools")
totals = {name: 0 for name in PATTERNS}
for path in root.rglob("*.py"):
text = path.read_text(encoding="utf-8", errors="ignore")
for name, pattern in PATTERNS.items():
hits = len(re.findall(pattern, text))
if hits:
totals[name] += hits
print(f"{path.relative_to(root)}: {name} ×{hits}")
print("\nsummary:", {k: v for k, v in totals.items() if v})
Breakdown: The patterns are deliberately simple and will produce some false positives — a comment mentioning QRegExp, a string containing :/plugins/ — which is fine for an audit whose job is to point at files, not to fix them. Unscoped enums are the largest category in most plugins, and the pattern list covers only the most common ones; the migration script in the next section finds the rest. .ui files are not scanned because Qt Designer files are XML that uic translates for both Qt versions, though custom widget plugins inside them still need checking.
Apply the automated fixes
The QGIS source repository ships a migration script, scripts/pyqt5_to_pyqt6/pyqt5_to_pyqt6.py, that rewrites the mechanical changes — enum scoping, exec_, moved classes — using an AST-based pass, so it understands context in a way regular expressions cannot.
git clone --depth 1 https://github.com/qgis/QGIS.git /tmp/qgis-src
cd /home/dev/plugins/asset_tools
git switch -c qt6-port
python3 /tmp/qgis-src/scripts/pyqt5_to_pyqt6/pyqt5_to_pyqt6.py .
git diff --stat
git diff | less
Breakdown: Running the script on a clean branch turns every automated change into a reviewable diff, which matters because the script occasionally scopes an enum on the wrong class when a name is ambiguous — the kind of error that only fails when that line runs. The script's own dependencies are listed at the top of the file; install them in a virtual environment rather than QGIS's Python. Reviewing the diff also teaches the new spellings, which helps when writing new code. Commit the script's output as one commit and manual fixes as separate ones, so a later bisect can tell them apart.
Manual fixes the script cannot make
A few changes need judgement. The common ones, with forms that work on both Qt versions:
import os
from qgis.PyQt.QtCore import QMetaType, QRegularExpression
from qgis.PyQt.QtGui import QIcon, QRegularExpressionValidator
try:
from qgis.PyQt.QtGui import QAction
except ImportError:
from qgis.PyQt.QtWidgets import QAction
from qgis.core import QgsField
PLUGIN_DIR = os.path.dirname(__file__)
def icon(name):
return QIcon(os.path.join(PLUGIN_DIR, "icons", name))
action = QAction(icon("export.svg"), "Export to asset system")
asset_id_validator = QRegularExpressionValidator(QRegularExpression(r"^[A-Z]{2}-\d{5}$"))
new_field = QgsField("inspected_on", QMetaType.Type.QDate)
result = dialog.exec()
Breakdown: PyQt6 does not include pyrcc, so compiled resources.py files are the most common hard blocker; loading icons from files next to the plugin by absolute path works identically on both versions and removes a build step. QAction moved to QtGui in Qt6, and the import fallback keeps older 3.x builds working. QRegExp and its validator no longer exist; QRegularExpression has been available throughout Qt5 and behaves the same for typical patterns, though it does not anchor automatically — add ^ and $. QgsField accepts QMetaType.Type from QGIS 3.38, which is the main reason to set that as the minimum version. exec() works on PyQt5 5.15 as well as PyQt6. Other removals to look for include QDesktopWidget (use QScreen via QGuiApplication.primaryScreen()) and QFontMetrics.width (use horizontalAdvance).
Declare support in metadata.txt
The plugin repository uses metadata.txt to decide which QGIS versions see a plugin. By default a plugin's maximum supported version is the .99 release of the major version of its minimum — so a plugin declaring qgisMinimumVersion=3.28 is assumed to stop at 3.99 and is hidden from QGIS 4 users.
[general]
name=Asset Tools
qgisMinimumVersion=3.38
qgisMaximumVersion=4.99
supportsQt6=True
version=3.0.0
changelog=
3.0.0 Runs on QGIS 4 (Qt6) and QGIS 3.38 or newer; icons no longer use compiled resources
Breakdown: qgisMaximumVersion=4.99 extends the range explicitly into the 4 series. supportsQt6=True is the flag the plugin repository and plugin manager use to mark a plugin as tested with Qt6 builds, which also covers Qt6 builds of late 3.x. Bumping the major version of the plugin signals the raised minimum QGIS version to users who pin versions. Writing the rest of the file is covered in writing metadata.txt for a QGIS plugin, and the release mechanics in versioning and changelog.
Test on both generations
A port is only done when the tests pass on the oldest supported 3.x release and on QGIS 4. A CI matrix with one job per QGIS image does that on every push.
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
qgis_image: ["qgis/qgis:3.40", "qgis/qgis:3.44", "qgis/qgis:4.0"]
container: ${{ matrix.qgis_image }}
steps:
- uses: actions/checkout@v4
- run: pip3 install --break-system-packages pytest pytest-qgis
- run: xvfb-run -a python3 -m pytest -q tests/
Breakdown: fail-fast: false lets every job finish, so a failure on QGIS 4 does not hide whether 3.40 still passes. Official qgis/qgis container images are published per release; check the tags available for the 4 series when you set this up. Running under xvfb-run gives Qt a virtual display for tests that create widgets. The workflow builds on running QGIS plugin tests in GitHub Actions, and dialog tests are where most Qt6 regressions show up, because enums and exec calls live in UI code.
QGIS version compatibility
The scoped enum forms have been accepted by PyQt5 for most Qt enums for years and by the QGIS API since the 3.2x releases; QMetaType field types need 3.38. The migration script lives in the QGIS source repository and improves over time, so fetch a current copy rather than an old one. QGIS 4 removes APIs deprecated during 3.x — the compiler warnings and deprecation notices in 3.40 and 3.44 are the best list of what else to check in your own code.
Troubleshooting
- The plugin does not appear in QGIS 4's plugin manager.
qgisMaximumVersionis missing or below 4.0. AttributeError: type object 'Qt' has no attribute 'AlignLeft'. An unscoped enum survived the port.- Icons are blank on QGIS 4. They came from a compiled
resources.py; load them from files. ImportError: cannot import name 'QAction'. Import fromQtGuiwith a fallback toQtWidgets.- Works on 3.44, fails on 3.34. The new spellings need a newer 3.x; raise
qgisMinimumVersion.
Conclusion
Audit the plugin, run the QGIS migration script on a branch, review its diff, and make the manual fixes: icons from files, QRegularExpression, QAction from QtGui, QMetaType field types and exec(). Set the minimum version to 3.38, declare qgisMaximumVersion=4.99 and supportsQt6=True, and prove it with a CI matrix that runs the tests on a 3.x LTR and on QGIS 4 from the same code.
Frequently Asked Questions
Do I need separate plugin versions for QGIS 3 and QGIS 4? Usually not. With scoped enums and the fixes above, one codebase runs on both.
Will .ui files from Qt Designer still work?
Yes. uic.loadUiType exists in both, though custom widgets referenced in the file must themselves be Qt6-compatible.
What about plugins that bundle compiled Python extensions? They must be rebuilt for the Python version QGIS 4 ships; see bundling third-party dependencies.
Can I test Qt6 before installing QGIS 4? Qt6 builds of late 3.x releases exist on some platforms and package managers; they catch most Qt-side problems early.