Migrationsalpha
How do stable variables get their values across upgrades — and how do I change their shape safely?
An upgrade replaces a canister's code while keeping its stable variables. The moment the new code wants a different shape of state than the old code left behind, something has to say how the old values become the new ones. Motoko has grown two answers, and moxzi implements both to the same verdicts as moc 1.14.1.
The migration expression (single-step)#
The original mechanism: one function, attached to the actor, run once at upgrade time.
import Migration "Migration";
(with migration = Migration.run)
persistent actor {
var newField : Nat = 0; // produced by Migration.run on upgrade
};
This works, is fully supported, and stays the right tool for a one-off change. Its limit is history: the function describes one step, so tooling that deploys many versions has to keep replacing it, and nothing records how far a given deployment already got.
The migration chain (--enhanced-migration)#
The chain turns migration from an expression into a directory:
moxzi build main.mo --enhanced-migration migrations/ -o main.wasm
Every *.mo file in the directory is one link, in filename order, each exporting a public migration function:
// migrations/001-init.mo — the first link: empty domain, runs on FRESH install
module {
public func migration(_ : {}) : { var count : Nat; var name : Text } {
{ var count = 1; var name = "one" };
};
};
// migrations/002-rename.mo — consumes `name`, produces `label_`; `count` is untouched
// and CARRIES through without being mentioned.
module {
public func migration(old : { var name : Text }) : { var label_ : Text } {
{ var label_ = old.name # "!" };
};
};
Under the flag, the rules of the actor body change:
- Stable variables have no initializers.
stable var count : Nat;declares the type; the value arrives through the chain. An initializer is an error (M0250) — inside a mixin too. - Side effects in the actor body are disallowed. State comes from the chain, not from code that runs on install.
- Transient fields are unrestricted —
transient let x = Map.empty<Nat>()is fine.
At runtime, each deployed version records which link it last applied (the label is the file name, embedded in the module's motoko:stable-types section). An upgrade fast-forwards past the links already applied and runs only the new ones. A fresh install runs the whole chain from the first link's empty domain.
The compiler checks, at build time, that the chain actually composes into the actor's stable fields: every field must be produced by some link, carried through untouched, or — on a first version — flagged. A field nothing produces is a warning without a baseline (M0254: the first version of a program has to start somewhere) and an error with one (M0267: there is a previous version, and it did not have the field).
--stable-baseline: telling the compiler what is deployed#
dfx canister metadata <id> motoko:stable-types > deployed.most
moxzi build main.mo --enhanced-migration migrations/ --stable-baseline deployed.most -o main.wasm
The baseline is the deployed canister's own signature, and it changes the chain check from advice into a verdict:
| Situation | Verdict |
|---|---|
| Every demanded field is explained by the baseline | silent |
| A field the chain demands is absent from the deployed state | M0267, naming the exact boundary (upgrade resuming after migration m2``) |
| A deployed field the new version would drop without a migration | M0169 |
| A deployed field's type is incompatible / would lose data | M0170 / M0216 |
One behaviour is worth calling out because moc 1.14.1 gets it wrong and moxzi deliberately does not: the check resumes where the deployment actually is. A Version 4.0.0 baseline records the last migration it applied; fields consumed and dropped by an already-applied migration are not demanded back. moc 1.14.1 replays the whole chain and errors on the first deploy after any field-dropping migration; upstream fixed this two days after the release (#6318, released as 1.14.1-fix-stable-baseline), and moxzi ships the fix. The four points a deployment can be at — no baseline, a legacy pre-chain baseline, mid-chain, fully applied — are pinned by scripts/enhanced_migration_gate.sh using upstream's own lifecycle fixture.
Checking an upgrade without building#
moxzi stable-compatible answers the narrower question — is this signature-to-signature upgrade safe? — without compiling anything:
dfx canister metadata <id> motoko:stable-types > old.most
moxzi stable-compatible old.most new.most
Silent exit 0 means compatible. Otherwise each line is one reason it is not, with moc's codes: M0169 (a stable variable dropped), M0170 (incompatible type), M0216 (subtyping holds but data would be lost anyway — the upgrade is type-correct and still wrong), M0255 (mixing chain and non-chain migration styles).
This is the same check the replica enforces at upgrade time; running it first turns a rejected upgrade into a build-time error.
Which one should I use?#
- A one-off shape change on a project deployed by hand: the migration expression.
- A project whose deploys are automated, or that expects to migrate more than once: the chain. The directory is the history, the deployed canister knows its own position in it, and
--stable-baselinemakes every deploy provably safe against what is actually running.
Next#
- Coming from moc — the flag mapping, including these.
- Error and diagnostic codes — M0169/M0170/M0216/M0250/M0254/M0267 in one table.
- Language support status — how this feature is measured.