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

python
"""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
What a migration step does A four-step migration: read the stored schema version, apply each step in order up to the current version, remove the keys the old schema owned, and record the new version so the work is never repeated. read version default 1 apply steps in order remove old keys no two sources record version idempotent if behind each step finally

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.

A settings schema across three releases A timeline of a settings schema: version one stores a distance in kilometres, version two renames and rescales it to metres, version three splits one endpoint into two, and each upgrade runs once at load and records the new schema number. one stored schema v1 buffer_km kilometres v2 buffer_distance metres, renamed v3 two endpoints one key split load migrate once then record

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.

How to change a setting without breaking anyone A decision tree over three kinds of change: a rename that carries the value across, a retype or rescale that transforms it, and a removal that simply deletes the key. What kind of change is this? renamed copy then remove value unchanged retyped convert explicitly never coerce blindly gone remove the key leave no orphan

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

python
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.