Adding a Plugin Page to the QGIS Options Dialog

Put plugin settings where users look for them: QgsOptionsPageWidget and QgsOptionsWidgetFactory, registering and unregistering the factory, why the reference…

TL;DR: subclass QgsOptionsPageWidget for the page and QgsOptionsWidgetFactory for the thing that builds it, register the factory in initGui(), keep a reference to it, and unregister it in unload() — read settings in the widget constructor and write them in apply(). This page is part of the plugin settings and configuration management guide.

Complete Runnable Code

python
"""A settings page inside the QGIS options dialog."""
from qgis.core import QgsSettings
from qgis.gui import QgsOptionsPageWidget, QgsOptionsWidgetFactory
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QCheckBox, QDoubleSpinBox, QFormLayout, QLineEdit

SECTION = "myplugin"
DEFAULTS = {"buffer_distance": 25.0, "service_url": "https://example.org/api",
            "simplify_output": True}


def read(key):
    default = DEFAULTS[key]
    return QgsSettings().value("%s/%s" % (SECTION, key), default, type=type(default))


def write(key, value) -> None:
    QgsSettings().setValue("%s/%s" % (SECTION, key), value)


class MyOptionsPage(QgsOptionsPageWidget):
    """One page in the QGIS options dialog. Built fresh each time it is opened."""

    def __init__(self, parent=None):
        super().__init__(parent)
        layout = QFormLayout(self)

        self.distance = QDoubleSpinBox()
        self.distance.setRange(0.0, 100000.0)
        self.distance.setSuffix(" m")
        self.distance.setValue(read("buffer_distance"))
        layout.addRow("Default buffer distance", self.distance)

        self.url = QLineEdit(read("service_url"))
        layout.addRow("Service endpoint", self.url)

        self.simplify = QCheckBox()
        self.simplify.setChecked(read("simplify_output"))
        layout.addRow("Simplify output geometry", self.simplify)

    def apply(self) -> None:
        """Called by QGIS when the user accepts the dialog — never on cancel."""
        write("buffer_distance", self.distance.value())
        write("service_url", self.url.text().strip())
        write("simplify_output", self.simplify.isChecked())


class MyOptionsFactory(QgsOptionsWidgetFactory):
    """Builds the page on demand and gives it its entry in the dialog's list."""

    def __init__(self):
        super().__init__()
        self.setTitle("My Plugin")
        self.setIcon(QIcon(":/plugins/myplugin/icon.svg"))

    def createWidget(self, parent=None) -> QgsOptionsPageWidget:
        return MyOptionsPage(parent)
How a page reaches the options dialog A four-step registration path: the plugin constructs a widget factory in initGui, registers it with the interface, QGIS calls createWidget when the user opens the dialog, and apply is called when the dialog is accepted. build the factory in initGui() register it iface call createWidget() when opened apply() on accept keep a ref on demand user accepts

Architecture Breakdown

QgsOptionsWidgetFactory — registered once, kept forever

The factory is the object QGIS holds on to. It supplies the title and icon shown in the dialog’s list, and it is asked for a widget each time the dialog opens. Registering it looks like this, inside the plugin’s initGui():

python
def initGui(self) -> None:
    self.options_factory = MyOptionsFactory()          # the reference matters
    self.iface.registerOptionsWidgetFactory(self.options_factory)


def unload(self) -> None:
    self.iface.unregisterOptionsWidgetFactory(self.options_factory)
    self.options_factory = None

Storing the factory on the plugin is not tidiness. Without a Python reference it is garbage collected while QGIS still holds a pointer, and the next attempt to open the options dialog crashes the application.

QgsOptionsPageWidget.apply() — the only place to write

QGIS calls apply() when the user accepts the dialog and does not call it when they cancel. That is the whole contract, and honouring it is what makes Cancel behave the way users expect.

What QGIS calls, and when A timeline of one options-dialog interaction: the factory is registered at plugin load, the widget is constructed when the user opens the dialog, edits happen in the widget, apply writes the values, and the widget is destroyed with the dialog. one options-dialog visit initGui() register factory once per session dialog opens createWidget() read settings here user edits widget state nothing stored yet accept apply() write settings here dialog closes widget destroyed factory survives

The corollary is that the widget must not write settings as the user types. A valueChanged signal wired straight to QgsSettings produces a page where Cancel changes nothing back, which is worse than having no Cancel at all.

Reading in the constructor

The widget is constructed each time the dialog opens, so its constructor is the natural place to read current values. That also means it always shows what is actually stored rather than what was stored when the plugin loaded — which matters when something else changed a setting in between.

Three places a plugin can put its settings UI A grid of the options dialog, a dedicated plugin dialog and a dock panel, showing where users look for each, how much room each gives you, and what each costs to maintain. users find it room costs options dialog first one page a factory to unregister plugin dialog via the menu as much as you like a whole dialog dock panel always visible narrow screen space, forever

Validating What the User Typed

A settings page is a user-input surface, and free-text fields are where configuration bugs come from. Validate in apply() and refuse to store something the plugin cannot use:

python
from qgis.PyQt.QtWidgets import QMessageBox


def apply(self) -> None:
    url = self.url.text().strip()
    if url and not url.startswith(("http://", "https://")):
        QMessageBox.warning(self, "My Plugin",
                            "The service endpoint must start with http:// or https://")
        return                       # leave the stored value untouched
    write("service_url", url)

Returning early leaves the previous value in place, which is the right failure mode: a plugin with its last known-good endpoint still works, while one that stored an unusable string does not. Where a value has a natural range, prefer a widget that cannot express an invalid one — a spin box with limits beats a line edit with a validator.

Laying the Page Out So It Reads Well

The options dialog is a shared space, and a page that ignores its conventions stands out in the wrong way. Three habits keep a plugin page looking like part of the application rather than something bolted on.

Group related controls under headings rather than presenting a flat list of twenty rows. A QFormLayout per group inside a QVBoxLayout gives you that with no custom painting, and it turns a wall of fields into three or four ideas a user can skim.

Label the unit rather than putting it in the field. A spin box with a " m" suffix cannot be misread; a field labelled “Buffer distance” whose value is silently in metres will eventually be filled in with kilometres. Where a value has a natural range, set it on the widget, so an impossible number is simply not typeable.

Finally, keep the page to what a user genuinely needs to change. Every control is a question you are asking, and a page that asks fifteen of them will have fourteen answered at random.

Production Best Practices

  • Keep a reference to the factory, or opening the options dialog will crash QGIS.
  • Unregister in unload() with the same object, or the page outlives the plugin.
  • Write only in apply(), so Cancel means cancel.
  • Read in the constructor, so the page always reflects what is stored now.
  • Use constrained widgets rather than validating free text wherever the value allows it.
  • Give the page an icon. The options dialog is a long list, and an unlabelled entry is effectively hidden.

Frequently Asked Questions

Why does opening the options dialog crash QGIS?

Almost certainly because the factory was garbage collected. QGIS keeps a raw pointer to the registered factory; if the only Python reference was a local variable in initGui(), the object is freed as soon as that method returns, and the next dialog open dereferences freed memory.

Assign it to an attribute on the plugin instance, and set that attribute to None only after unregistering.

Can I have more than one page?

Yes — register one factory per page. Each appears as its own entry in the options list. For a plugin with a handful of related settings this is usually worse than one page with grouped sections, because the options dialog belongs to QGIS and filling it with entries from one plugin is not neighbourly.

How do I add my page to a specific position in the list?

QgsOptionsWidgetFactory exposes setKey() and, in recent versions, path helpers that place the page within an existing group. Absolute ordering is not under your control, and it should not be: the list is shared with the application and every other plugin.

Should the page write to the project instead of settings?

Only if the values genuinely belong to the project. The options dialog is where users expect application preferences, so a page that quietly modifies the current project is surprising. When a value is project-scoped, put it in a plugin dialog with the project’s name visible, so it is obvious what the change applies to.

How do I react to a setting changing elsewhere?

There is no change signal on QgsSettings, so the answer is to read values when you use them rather than caching them at load. For a value that is expensive to act on — an endpoint that implies reconnecting — expose a small function in your plugin that applies the current settings, and call it from apply() as well as at start-up.

That keeps one code path for “make the plugin match the settings”, which is much easier to reason about than a set of signals that each update part of the state.

Does the page work when no project is open?

It should. QGIS can be running with an empty project, and the options dialog is still available, so a page that assumes a project — reading layers to fill a combo box, for instance — must handle the empty case. Populate such widgets lazily and tolerate an empty list rather than raising in the constructor.