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.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- A plugin with settings to store; user-level settings are covered in storing plugin settings with QgsSettings.
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.
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.
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
readEntrywas 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.