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.

Where the porting work isTwo columns compare QGIS 3 on Qt5 with QGIS 4 on Qt6. Rows: enums, unscoped Qt.AlignLeft accepted versus only Qt.AlignmentFlag.AlignLeft; dialogs, exec_ versus exec; actions, QAction in QtWidgets versus QtGui; regular expressions, QRegExp versus QRegularExpression; resources, pyrcc5 compiled resources.py versus icons loaded from files; field types, QVariant.String versus QMetaType.Type.QString. A note says the QGIS API calls themselves mostly work on both when written with scoped QGIS enums.Mostly Qt changes, rarely QGIS onesareaQt5 habitworks on bothenumsQt.AlignLeftQt.AlignmentFlag.AlignLeftdialogsdlg.exec_()dlg.exec()actionsQtWidgets.QActionQtGui.QAction (with fallback)regexQRegExpQRegularExpressionresourcespyrcc5 resources.pyicons loaded by file pathfield typesQVariant.StringQMetaType.Type.QString

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 QMetaType field types.
  • Imports that already go through qgis.PyQt rather than PyQt5 directly. 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.

Port in a reviewable sequenceSix steps. Audit the plugin with a pattern scan. Run the QGIS migration script on a clean branch so every change is a diff. Review the diff by hand, because scoping an enum on the wrong class breaks at runtime. Apply manual fixes: resources, QVariant field types, removed classes. Run the test suite on QGIS 3.40 or 3.44 and on QGIS 4. Update metadata.txt and release.Every change lands as a diff someone reviews1 auditsize the job2 scriptmechanical fixes3 reviewread the diff4 manualresources, types5 test3.x LTR + 4.x6 releasemetadata.txtone codebase for both generations —no qgis3/ and qgis4/ forks to maintain

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.

Tell the repository the plugin is readyLeft: metadata with qgisMinimumVersion 3.28 only. The implied maximum is 3.99, so QGIS 3.40 and 3.44 users see the plugin and QGIS 4 users do not. Right: metadata with qgisMinimumVersion 3.38, qgisMaximumVersion 4.99 and supportsQt6 equals True. QGIS 3.38 and later and QGIS 4 users all see it.A ported plugin nobody can install is not portedbeforeqgisMinimumVersion=3.28implied maximum: 3.99hidden in QGIS 4afterqgisMinimumVersion=3.38qgisMaximumVersion=4.99supportsQt6=Trueoffered to 3.38+ and QGIS 4

[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. qgisMaximumVersion is 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 from QtGui with a fallback to QtWidgets.
  • 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.