Add a Plugin Options Page to QGIS Settings

Every plugin with preferences faces the same small design question, and most answer it badly: a separate "Settings" item in the plugin menu, opening a bespoke dialog that looks nothing like QGIS and that users forget exists. QGIS offers the alternative in a few dozen lines — a page inside Settings → Options, next to the application's own, searchable, consistently styled, and exactly where somebody looking for preferences will look.

This recipe belongs to Plugin Settings and Localization. It covers the page widget, the factory that publishes it, loading and applying values, validation, and the unregistration that stops a reloaded plugin from crashing the options dialog.

Where a registered options page appearsThe QGIS options dialog lists its built-in pages down the left side: general, system, coordinate reference systems, rendering and others. A registered plugin page appears in the same list with its own title and icon. Selecting it shows the plugin's widget on the right, and pressing OK calls the widget's apply method, which writes to settings.Your preferences, in the place preferences liveSettings and OptionsGeneralSystemRenderingParcel ToolsAdvancedyour widgetoutput folderadd result to mapbuffer distanceOK pressedapply() is calledQgsSettingsvalues persisted

Prerequisites

Build the page widget

from qgis.PyQt.QtWidgets import (QVBoxLayout, QFormLayout, QLineEdit,
                                 QCheckBox, QDoubleSpinBox)
from qgis.core import QgsSettings
from qgis.gui import QgsOptionsPageWidget

PREFIX = "parcel_tools"


class ParcelToolsOptionsPage(QgsOptionsPageWidget):
    def __init__(self, parent=None):
        super().__init__(parent)

        self.output_folder = QLineEdit()
        self.add_to_map = QCheckBox()
        self.buffer_distance = QDoubleSpinBox()
        self.buffer_distance.setRange(0.0, 10000.0)
        self.buffer_distance.setSuffix(" m")

        form = QFormLayout()
        form.addRow(self.tr("Output folder"), self.output_folder)
        form.addRow(self.tr("Add result to map"), self.add_to_map)
        form.addRow(self.tr("Default buffer distance"), self.buffer_distance)

        layout = QVBoxLayout()
        layout.addLayout(form)
        layout.addStretch()
        self.setLayout(layout)

        self.load_settings()

    def load_settings(self):
        settings = QgsSettings()
        self.output_folder.setText(
            settings.value(f"{PREFIX}/output_folder", "", type=str))
        self.add_to_map.setChecked(
            settings.value(f"{PREFIX}/add_to_map", True, type=bool))
        self.buffer_distance.setValue(
            settings.value(f"{PREFIX}/buffer_distance", 25.0, type=float))

    def apply(self):
        settings = QgsSettings()
        settings.setValue(f"{PREFIX}/output_folder", self.output_folder.text())
        settings.setValue(f"{PREFIX}/add_to_map", self.add_to_map.isChecked())
        settings.setValue(f"{PREFIX}/buffer_distance", self.buffer_distance.value())

Breakdown: QgsOptionsPageWidget is a plain widget with one contract — QGIS calls apply() when the user accepts the dialog. Loading in the constructor is right because the widget is created each time the page is opened, so it always shows current values. Using a QFormLayout rather than absolute positions is what lets the page survive translation, where German labels can be half again as long; the trailing addStretch() keeps the fields at the top instead of spread down the page. Wrapping every label in self.tr() costs nothing now and makes the page translatable later.

Publish it with a factory

from qgis.PyQt.QtGui import QIcon
from qgis.gui import QgsOptionsWidgetFactory


class ParcelToolsOptionsFactory(QgsOptionsWidgetFactory):
    def __init__(self, plugin_dir):
        super().__init__()
        self.setTitle("Parcel Tools")
        self.setIcon(QIcon(f"{plugin_dir}/icon.png"))

    def createWidget(self, parent=None):
        return ParcelToolsOptionsPage(parent)

Breakdown: The factory exists so QGIS can create the page lazily — nothing is built until the user opens that page, which keeps the options dialog fast however many plugins register. The title is what appears in the list on the left, so keep it to the plugin's name rather than "Settings". An icon is optional but makes the entry findable in a long list. createWidget() must return a new instance each time; returning a cached one produces a page that shows stale values after a cancel.

Register and unregister

class ParcelTools:
    def __init__(self, iface):
        self.iface = iface
        self.options_factory = None

    def initGui(self):
        self.options_factory = ParcelToolsOptionsFactory(self.plugin_dir)
        self.iface.registerOptionsWidgetFactory(self.options_factory)

    def unload(self):
        if self.options_factory is not None:
            self.iface.unregisterOptionsWidgetFactory(self.options_factory)
            self.options_factory = None

Breakdown: Keeping a reference on the plugin object is not optional: a factory created as a local variable is garbage-collected while QGIS still holds a pointer to it, and the options dialog then crashes the application rather than raising. The unregister in unload() is equally load-bearing — unload() runs on every plugin reload during development, and without it each reload adds another page, all but one of which point at torn-down objects. Setting the attribute back to None makes a double unload harmless, which happens more often than you would expect during a reload cycle.

What three reloads look like, with and without unregisteringWith unregistration in unload, each reload replaces the previous page and the options dialog shows exactly one entry. Without it, each reload adds another entry, and the older entries point at plugin objects that have been destroyed, so opening them crashes QGIS.The bug appears only after you reload during developmentunregistered in unload()reload 1 — Parcel Toolsreload 2 — Parcel Toolsstill exactly one pageunregister forgottenParcel Tools — dead objectParcel Tools — dead objectopening one crashes QGIS

Validate before applying

apply() is called when the user presses OK, and QGIS does not expect it to fail. Validate in a way that guides rather than blocks.

import os
from qgis.core import Qgis

    def apply(self):
        folder = self.output_folder.text().strip()

        if folder and not os.path.isdir(folder):
            self.iface.messageBar().pushMessage(
                self.tr("Parcel Tools"),
                self.tr("Output folder does not exist; keeping the previous value."),
                level=Qgis.Warning,
                duration=6,
            )
            return

        settings = QgsSettings()
        settings.setValue(f"{PREFIX}/output_folder", folder)

Breakdown: Refusing to store an invalid value and telling the user why is better than either storing nonsense or throwing an exception out of apply(), which QGIS surfaces as an unhelpful stack trace. The message bar is the right channel because the dialog is closing as this runs — a modal warning at this moment fights with the dialog's own dismissal. Better still, prevent the problem at input: a QgsFileWidget in directory mode gives a browse button and only produces real paths, which removes most of this validation entirely.

Two places to put the same five settingsA bespoke dialog reached through the plugin menu has to be found, looks different from the rest of QGIS, and is invisible to the options search. A page registered in the QGIS options dialog sits where users already look for preferences, inherits the application styling, and is indexed by the search box.Same settings, very different odds of being foundquestiona dialog in your menua page in Optionswhere users look firstnot thereexactly therelooks like QGISonly if you copy the styleinherited for freefound by the search boxneveryes, by label text

Design the page like the rest of QGIS

A page that lives inside the application's own settings dialog is held to the application's conventions, and a handful of habits keep it from looking like a visitor.

Group by concern, not by data type. Three group boxes labelled "Output", "Processing defaults" and "Connections" are far easier to scan than twelve rows in one list. QGIS's own pages do this consistently, and a plugin that follows suit reads as part of the software rather than bolted onto it.

Say what the setting does, not what it is called internally. "Add result to map after running" beats "auto_add"; the key name belongs in the code and nowhere else. Where a setting has a non-obvious consequence, a short description under the control is better than a tooltip nobody hovers over.

Give every control a sensible default and show it. A spin box that starts at zero when the real default is twenty-five metres teaches the user the wrong thing. Load from settings with the same default the rest of the plugin uses, so the page always shows what will actually happen.

Do not put actions on a settings page. Buttons that run something, clear a cache or test a connection are borderline; a test-connection button is genuinely useful and widely accepted, but anything that modifies data belongs in the plugin's own interface. Users reasonably expect that pressing Cancel undoes everything they did in this dialog, and an action button breaks that expectation.

Keep it short. If the page needs a scroll bar, some of those settings are probably decisions your plugin could make for itself. Every option is a question asked of every user forever, so the bar for adding one should be higher than "somebody might want this".

QGIS version compatibility

QGIS versionPythonNotes
3.22 LTR3.9QgsOptionsWidgetFactory, QgsOptionsPageWidget and the register calls as described.
3.28 LTR3.9Identical.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Identical; the options dialog gained a search box that indexes your page's labels, so clear label text now has an extra payoff.

setKey() on the factory sets a stable identifier used for deep-linking to a page, and is worth setting on newer releases if you want a menu action that opens your page directly.

Troubleshooting

  • The page does not appear. The factory was not kept as an attribute and has been garbage-collected, or registerOptionsWidgetFactory was never reached because initGui() raised earlier.
  • QGIS crashes when the options dialog opens. A stale factory from a previous load. Unregister in unload() and restart once to clear the existing ones.
  • Values are not saved. apply() is not being reached, usually because the method name is misspelled — it must be exactly apply, with no arguments.
  • Old values reappear after cancel. createWidget() returned a cached widget instead of a new one.
  • Labels are clipped in another language. A fixed-width layout. Use QFormLayout or a grid and let Qt size the fields.
  • Two pages with the same title. Two registrations, one per reload. See the crash case above.

Conclusion

Subclass QgsOptionsPageWidget, load settings in the constructor and write them in apply(), publish it with a QgsOptionsWidgetFactory kept as an attribute on the plugin, and unregister in unload(). The result is preferences where users expect them, styled like the rest of QGIS, searchable, and translatable — for roughly the same amount of code as the bespoke dialog nobody finds.

Frequently Asked Questions

Can I open my options page from a toolbar button? Yes — call iface.showOptionsDialog() with the page name, or set a key on the factory and use it as the current page on newer releases.

Where should the page's help text go? A short description at the top of the widget, and a link to your documentation. QGIS does not provide a standard help slot for plugin pages.

Can I add settings for a Processing provider this way? Processing providers have their own settings mechanism inside the Processing options page. Use that for algorithm-level configuration and this page for the plugin's own.

Does the page need to handle Cancel? No. Nothing is written until apply() is called, so cancelling simply discards the widget.

How do I group many settings on one page? Use QGroupBox sections inside the layout, or a QTabWidget for genuinely separate concerns. Two pages registered by one plugin is also acceptable when the concerns are unrelated.