Store Per-Project Plugin Settings in PyQGIS

Some plugin settings belong to the user: the last folder, preferred units, whether to show a welcome message. Others belong to the project: which layer holds the assets, which field is the identifier, which API endpoint this project syncs with, the thresholds agreed for this study area. Storing project-specific settings in the user profile breaks as soon as the user opens a second project — or a colleague opens the first. QGIS projects have their own key–value store for plugins, saved inside the .qgz, so settings travel with the project and every user who opens it sees the same configuration.

This recipe belongs to Plugin Settings & Localization. It writes and reads project entries with types, stores layer references robustly, reacts to projects being opened and saved, and helps decide which storage — project, user or layer — each setting needs.

Where a setting belongsUser settings via QgsSettings live in the profile and follow the person across projects: last folder, units, UI preferences. Project entries via writeEntry live in the project file and follow the project across people: which layer, which endpoint, agreed thresholds. Layer custom properties live with one layer and travel in its style: per-layer field mappings. Choosing the right store is the main design decision.Follows the person, the project, or the layer?QgsSettingsuser profilelast folder, unitsUI preferencesfollows the personproject entriesinside .qgzwhich layer, endpointthresholdsfollows the projectlayer propertieswith one layerfield mappingtravels in style

Prerequisites

Write and read project entries

Project entries are grouped by a scope — your plugin's name — and a key. Typed write and read methods keep values as numbers, booleans and lists rather than strings.

from qgis.core import QgsProject

SCOPE = "AssetSync"
project = QgsProject.instance()

project.writeEntry(SCOPE, "endpoint", "https://assets.example.org/api/v2")
project.writeEntryDouble(SCOPE, "tolerance_m", 0.5)
project.writeEntryBool(SCOPE, "auto_sync", True)
project.writeEntry(SCOPE, "sync_fields", ["asset_id", "status", "inspected_on"])

endpoint, ok = project.readEntry(SCOPE, "endpoint", "")
tolerance, _ = project.readDoubleEntry(SCOPE, "tolerance_m", 1.0)
auto_sync, _ = project.readBoolEntry(SCOPE, "auto_sync", False)
fields, _ = project.readListEntry(SCOPE, "sync_fields", [])
print(endpoint, tolerance, auto_sync, fields, "found" if ok else "default used")

Breakdown: The scope keeps your keys separate from other plugins' — use your plugin's name. Each read returns the value and a flag saying whether it was found, so code can tell a stored value from the default. Typed readers return the right Python type: readDoubleEntry a float, readBoolEntry a bool, readListEntry a list of strings. Writing marks the project as modified; the values are saved into the project file the next time it is saved, by the user or by your code.

Store layer references that survive

The most common project setting is "which layer". Storing the layer's name breaks when it is renamed; storing its id survives renaming, reordering and grouping.

Store ids, not namesA setting that stores the layer name assets fails when a user renames the layer to Assets 2026. Storing the layer id, a stable identifier assigned when the layer was added, keeps working after renames. On read, the id is looked up with mapLayer; if the layer was removed, the plugin asks the user to choose again.Names change; ids do notby name"assets"renamed → lostby id"assets_8f3c…"renamed → still foundremoved → ask again

def set_asset_layer(layer):
    project.writeEntry(SCOPE, "asset_layer_id", layer.id())

def asset_layer():
    layer_id, ok = project.readEntry(SCOPE, "asset_layer_id", "")
    layer = project.mapLayer(layer_id) if ok and layer_id else None
    if layer is None:
        return None          # not configured, or the layer was removed
    return layer

lyr = asset_layer()
print(lyr.name() if lyr else "asset layer not configured")

Breakdown: layer.id() is generated when a layer is added and stored in the project, so it stays the same across sessions and renames. Looking it up with mapLayer returns None if the layer has since been removed, which the plugin should treat as "not configured" and prompt the user — for example by opening its settings dialog with a layer combo box. Field names are fine to store by name, since renaming fields is rarer and their names are what users see.

Group settings in a small class

Reading and writing scattered entries throughout a plugin makes settings hard to change. A small class with properties gives one place for keys, types and defaults.

class ProjectSettings:
    def __init__(self, project=None, scope=SCOPE):
        self.p = project or QgsProject.instance()
        self.scope = scope

    @property
    def endpoint(self):
        return self.p.readEntry(self.scope, "endpoint", "")[0]

    @endpoint.setter
    def endpoint(self, value):
        self.p.writeEntry(self.scope, "endpoint", value)

    @property
    def tolerance_m(self):
        return self.p.readDoubleEntry(self.scope, "tolerance_m", 0.5)[0]

    @tolerance_m.setter
    def tolerance_m(self, value):
        self.p.writeEntryDouble(self.scope, "tolerance_m", float(value))

    def is_configured(self):
        return bool(self.endpoint) and asset_layer() is not None

cfg = ProjectSettings()
cfg.tolerance_m = 0.75
print(cfg.endpoint, cfg.tolerance_m, cfg.is_configured())

Breakdown: Properties hide the scope, keys and typed calls, so the rest of the plugin reads cfg.tolerance_m like an ordinary attribute. Defaults live in one place. is_configured answers the question every action asks before running. Adding a setting is a matter of adding a property, and renaming a key — for a new plugin version — happens in one spot, where a migration from the old key can also live.

React to projects being opened and saved

Settings must be re-read when the user opens another project, and the plugin may want to write its state just before the project is saved.

Project lifecycle signalsWhen a project is read, readProject fires and the plugin reloads its settings and updates its interface. When a project is cleared, cleared fires and the plugin resets to the unconfigured state. Just before saving, writeProject fires and the plugin writes any pending state. Connecting all three keeps the plugin in step with whichever project is open.Read, clear, writereadProjectreload settingsupdate UIclearedreset tounconfiguredwriteProjectwrite pendingstate

class AssetSyncPlugin:
    def __init__(self, iface):
        self.iface = iface
        self.cfg = ProjectSettings()

    def initGui(self):
        p = QgsProject.instance()
        p.readProject.connect(self.on_project_read)
        p.cleared.connect(self.on_project_cleared)
        p.writeProject.connect(self.on_project_write)
        self.on_project_read()

    def on_project_read(self, *args):
        state = "configured" if self.cfg.is_configured() else "not configured"
        self.iface.messageBar().pushInfo("Asset Sync", f"Project {state}")

    def on_project_cleared(self):
        pass                                   # reset dock widgets, caches

    def on_project_write(self, *args):
        QgsProject.instance().writeEntry(SCOPE, "last_saved_by_plugin", "1.4.0")

    def unload(self):
        p = QgsProject.instance()
        for sig, slot in ((p.readProject, self.on_project_read), (p.cleared, self.on_project_cleared),
                          (p.writeProject, self.on_project_write)):
            try:
                sig.disconnect(slot)
            except TypeError:
                pass

Breakdown: readProject fires after a project has been loaded, the right moment to read settings and refresh the plugin's interface. cleared fires when the project is closed or a new one started. writeProject fires as the project is being saved, so entries written in the handler are included in the file — useful for recording the plugin version that last saved the project, which helps migrations. Disconnecting in unload prevents handlers from firing after the plugin is reloaded.

Choose the right store

Each store has a clear purpose; mixing them is the source of most "my settings disappeared" reports.

from qgis.core import QgsSettings

QgsSettings().setValue("AssetSync/last_export_folder", "/data/exports")   # user
project.writeEntry(SCOPE, "endpoint", "https://assets.example.org/api/v2")  # project
asset_layer().setCustomProperty("assetsync/id_field", "asset_id")          # layer

Breakdown: User preferences that should follow a person across projects go in QgsSettings. Configuration that defines how this project works — endpoints, thresholds, layer roles — goes in project entries. Configuration that belongs to one layer wherever it is used — its id field, its sync mapping — goes in layer custom properties, which also travel in the layer's QML style; a custom layer properties page is the natural editor for them. Never store secrets in any of these; use the authentication system, as in storing credentials with QgsAuthManager.

Migrate settings between plugin versions

Projects outlive plugin versions. A project saved with version 1.2 of your plugin may be opened years later with version 2.0, after keys have been renamed or values restructured. A small migration step on readProject keeps old projects working.

SCHEMA_VERSION = 2

def migrate_project_settings(project):
    version, _ = project.readNumEntry(SCOPE, "schema_version", 1)
    if version < 2:
        old_layer, found = project.readEntry(SCOPE, "layer_name", "")
        if found and old_layer:
            matches = project.mapLayersByName(old_layer)
            if matches:
                project.writeEntry(SCOPE, "asset_layer_id", matches[0].id())
            project.removeEntry(SCOPE, "layer_name")
    if version < SCHEMA_VERSION:
        project.writeEntry(SCOPE, "schema_version", SCHEMA_VERSION)

Breakdown: A stored schema number tells the plugin which layout of keys the project uses. Here version 1 stored the layer by name; the migration looks it up once, stores the id, and removes the old key. Each later change adds another if version < n block, so a project can jump several versions in one load. Writing the new version marks the project modified, which reminds the user to save it so the migration is kept.

Test project settings without the interface

Because entries live on the QgsProject object, tests can create a fresh project, write values, save to a temporary file, read it back and check the round trip — without a running QGIS window.

import tempfile, os
from qgis.core import QgsProject

def test_round_trip():
    p = QgsProject()
    cfg = ProjectSettings(project=p)
    cfg.tolerance_m = 2.5
    path = os.path.join(tempfile.mkdtemp(), "t.qgz")
    assert p.write(path)
    q = QgsProject()
    assert q.read(path)
    assert ProjectSettings(project=q).tolerance_m == 2.5

Breakdown: Passing a project into ProjectSettings instead of always using QgsProject.instance() is what makes this testable. The test proves both that values are written with the right type and that they survive saving, which catches the commonest bug — writing an entry after the project was saved. Run it with the setup from testing QGIS plugins with pytest.

QGIS version compatibility

writeEntry, the typed writeEntryDouble/writeEntryBool and the corresponding read methods, and the project signals are available on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. Project entries are stored in the project XML under properties, so they survive across versions and are readable without the plugin installed.

Troubleshooting

  • Settings vanish when reopening the project. The project was not saved after writing entries.
  • The plugin uses the previous project's settings. Settings were cached and not re-read on readProject.
  • A stored layer is not found. It was removed, or the value stored is a name rather than an id.
  • Values come back as strings. The untyped readEntry was used for numbers; use the typed readers.

Conclusion

Store project-specific configuration with writeEntry under your plugin's scope and read it with typed readers, reference layers by id and handle removed layers, wrap settings in a small class with defaults, reload on readProject and write pending state on writeProject, and keep user preferences in QgsSettings and per-layer configuration in layer properties.

Frequently Asked Questions

Can I see project entries without the plugin? Yes — they are in the project XML; open the .qgs inside the .qgz to inspect them.

Do project entries work for projects stored in PostgreSQL? Yes; they are part of the project, wherever it is stored.

How do I remove an entry?project.removeEntry(scope, key).

Can project variables replace entries? For values used in expressions, yes; for structured plugin configuration, entries are cleaner.