Migrating Plugin Settings Between Releases
Change a plugin settings schema without breaking existing installs: a stored schema version, ordered idempotent migration steps, removing superseded keys,…
TL;DR: store a schema version alongside your settings, run an idempotent migrate() at plugin load that applies each step in order, and always remove the old key after copying its value — otherwise two sources of truth survive and the next migration cannot tell which is current. This page is part of the plugin settings and configuration management guide.
Complete Runnable Code
"""An idempotent settings migration, safe to run on every plugin load."""
import logging
from qgis.core import QgsSettings
log = logging.getLogger("myplugin")
SECTION = "myplugin"
SCHEMA = 3 # bump this whenever a step is added below
def _key(name: str) -> str:
return "%s/%s" % (SECTION, name)
def migrate() -> int:
"""Bring stored settings up to `SCHEMA`. Returns the version migrated from."""
settings = QgsSettings()
stored = settings.value(_key("schema"), 1, type=int)
if stored >= SCHEMA:
return stored
if stored < 2:
# v2 renamed buffer_km and changed its units to metres
old = settings.value(_key("buffer_km"), None, type=float)
if old is not None:
settings.setValue(_key("buffer_distance"), old * 1000.0)
settings.remove(_key("buffer_km"))
log.info("settings: migrated buffer_km -> buffer_distance (metres)")
if stored < 3:
# v3 split one endpoint into a read and a write endpoint
endpoint = settings.value(_key("service_url"), "", type=str)
if endpoint:
settings.setValue(_key("read_url"), endpoint)
settings.setValue(_key("write_url"), endpoint)
settings.remove(_key("service_url"))
log.info("settings: split service_url into read_url and write_url")
settings.setValue(_key("schema"), SCHEMA)
return stored
Architecture Breakdown
The stored version is the whole mechanism
Without a recorded version there is no way to know whether a migration has run, and every alternative is worse. Inferring it from which keys are present works until two releases add keys in a way that overlaps; running the migration unconditionally works until a step is not idempotent, at which point a value is scaled by a thousand twice.
An integer under a schema key costs nothing and makes the question decidable. Default it to 1 rather than 0, so a first install with no stored settings is treated as the earliest schema rather than as something special.
Steps run in order, and each is if stored < n
Writing the steps as a sequence of independent if blocks means a user upgrading from version one to version three runs both steps, in order, in a single load. A structure that switches on the stored version instead — if stored == 1: ... — silently skips intermediate steps for anyone who missed a release, which is most people.
Removing the old key is not optional
Copying a value and leaving the original behind creates two keys holding the same thing. That is harmless until the user downgrades, edits the setting in the old release, and upgrades again — at which point the migration has already recorded its version and never runs, so the value they just set is ignored in favour of the copy.
Removing the old key as part of the same step keeps exactly one source of truth for each value.
Deciding What Kind of Change You Are Making
Not every schema change needs the same treatment, and naming which of the three you are making keeps the migration step short enough to be obviously correct.
Renames are the easy case: copy, remove, done. Retypes need explicit conversion, because the platform’s coercion cannot be trusted — a value stored as the string "25" read with type=float usually works and occasionally does not, depending on locale and platform.
Removals deserve more thought than they get. A key that no longer means anything should be removed, not left behind, because settings files persist for years and a stale key is a trap for the next person who greps for it. If a setting is being retired but its value still matters, that is a rename to something else rather than a removal.
Where to Call It
class MyPlugin:
def __init__(self, iface):
self.iface = iface
def initGui(self) -> None:
previous = migrate() # before anything reads a setting
if previous < SCHEMA:
self.iface.messageBar().pushInfo(
"My Plugin", "Settings upgraded from schema %d." % previous)
self._build_ui()
initGui() is the right place: it runs once per session, after the profile is resolved, and before any of your code reads a value. Calling it at import time is too early — the settings system is not guaranteed to be pointed at the right profile yet.
Keeping the Number of Migrations Small
Every migration step is code that lives forever: it has to keep working for anyone upgrading from any earlier release, and it can never be deleted without stranding those users. That is a good reason to think twice before changing a settings key at all.
Two habits reduce how often you need to. Choose key names that describe the value rather than its current unit or format — buffer_distance survives a change from kilometres to metres in a way buffer_km does not. And avoid encoding structure into a key name: a single key holding a JSON document can change shape without a rename, at the cost of validating it on read.
When a change is genuinely needed, prefer adding a new key with a sensible default over transforming an old one. The migration is then a no-op for most users, and the old key can be retired quietly a release or two later when almost nobody still has it.
Production Best Practices
- Make
migrate()idempotent. It will be called on every load, forever. - Bump the schema constant in the same commit as the step. They are one change.
- Log each migration. When a user reports a wrong value after an upgrade, the log is the only evidence of what happened to it.
- Remove old keys so there is never more than one source of truth.
- Never migrate on write. A migration that runs when a value is saved leaves users who never open the settings page permanently on the old schema.
- Test the two-release jump, not just the one-release one. Most users skip versions.
Frequently Asked Questions
What if the user downgrades the plugin?
The old release sees a schema number higher than it knows about and its own migrate() returns immediately, which is correct — it has nothing to do. What it will find is keys it does not recognise and, possibly, the absence of keys it expects, so it falls back to defaults.
That is an acceptable outcome and worth designing for: a downgrade should degrade to defaults rather than fail. Guaranteeing a downgrade preserves settings would mean never removing a key, which costs more than it is worth.
Should migrations ever delete user data?
No. Settings migration should be conservative: convert, rename, remove keys that no longer have meaning. Anything that could destroy a value the user set deliberately — clearing a list, resetting a path — should be a prompt rather than a silent step, and even then it is worth asking whether the new release could simply tolerate the old shape.
How do I test a migration?
Point QSettings at a temporary INI file in a fixture, write the old keys into it, run migrate(), and assert on the new ones. Because the migration is ordinary Python touching one API, this needs no QGIS interface at all and runs in milliseconds.
Test the two-version jump as its own case. It is the path most users take and the one most often broken by a step that assumed the previous release had already run.
What if a migration step fails halfway?
The schema version is only written at the end, so a step that raises leaves the version unchanged and the migration runs again on the next load. That is the behaviour you want, provided each individual step is idempotent — which is why the steps copy and remove rather than mutating in place.
Wrap the whole thing in a try/except that logs and re-raises during development, and logs without re-raising in a release: a plugin that refuses to load because a settings migration failed is worse than one running on partially migrated settings.
Can I migrate project-scoped settings the same way?
The principle carries over, but the mechanism is different: project entries live in the project file, so the migration has to run when a project is read rather than when the plugin loads. Connect to QgsProject.instance().readProject and apply the same version-and-steps pattern, then mark the project dirty so the upgraded values are saved.
Related
- Plugin Settings and Configuration Management — the parent guide covering stores and namespacing
- Adding a Plugin Page to the QGIS Options Dialog — the page a migrated value shows up in
- Plugin Lifecycle and Resource Management — why initGui is the right place to run it