Motoko Actor Migrations
Expert guidance for migrating actor state across canister upgrades with orthogonal persistence.
Critical: Never use classical persistence or pre/post upgrade
Do not use classical (legacy) persistence or system func preupgrade / system func postupgrade. They are error-prone, can leave canisters stuck if they trap, and do not scale.
Also do not use the legacy (with migration = ...) actor-attached syntax. This skill covers mops-managed migrations. When a change requires an explicit migration, write it as a timestamped file in src/backend/migrations/; the chain replays automatically.
How stable state is initialized
Stable actor fields have no initializers in the actor body. The chain in src/backend/migrations/ is what gives them values: the runtime walks it in lexicographic order, running every file on a fresh install and only the files not yet applied to the canister on an upgrade.
When to Use This Skill
- Planning or implementing a canister upgrade that changes actor state
- Deciding between implicit vs explicit migration
- Writing or refactoring a migration function
- Introducing stable state for the first time (use a NEW migration file with
OldActor = {})
Migration Folder Rules
- All migrations live in
src/backend/migrations/. The full chain replays in lexicographic filename order on fresh install; on upgrade, only entries newer than the deployed tail run. (The directory is declared aschain = ...under[canisters.<name>.migrations]inmops.toml; an imported project using a non-default canister name may place it elsewhere — readmops.tomlrather than assuming.) - At most one pending migration per build (
check-limit = 1inmops.toml). If this build already added a migration file, edit that file to fold in further changes instead of adding another.mops checkcompares the deployed.mostbaseline and names the latest pending file to fold into when the limit is exceeded. Where a hosting platform owns the migrations section andcheck-limit, never edit them to clear an error. - Name new files with just the UTC timestamp, no suffix:
YYYYMMDD_HHMMSS.mo. The timestamp must sort after every existing file. Do NOT encode the change in the name (noAddPriority,AddTags,Init, …) — any feature-ish name tempts you to add another file for the next change instead of editing the one file you already have this build. - Never modify, delete, or rename migration files that existed before this build started. Applied migrations are tracked by module name, so a rename makes the runtime treat the file as never applied, and an edit to an already-applied file never executes. Some platforms enforce this by making deployed migrations read-only, in which case writes to them simply fail. A migration created earlier in the same build is not applied yet: edit it rather than add a second migration for the same change.
- Migrations must be self-contained. Inline BOTH old types AND new types in the migration file. Only
mo:core/...imports are allowed — never../typesor any project module. The chain replays forever; a frozen migration that importedTypes.Notebecomes wrong the momentNotechanges in an incompatible way. mops check --fixautomatically verifies upgrade compatibility.
Two Kinds of Migration
1. Implicit migration (no code)
The runtime allows the upgrade if the new program is compatible with the old. No migration function needed.
Do NOT add a migration file for changes in the implicit list below. Every migration file replays forever on fresh install; adding a no-op file bloats the chain, slows fresh installs, and creates a frozen artifact you can never delete. A long enough chain becomes undeployable in a way no retry recovers, and
mops checkwill not warn you. Fewer migrations are always better.Identity body = no migration. If your
(old : OldActor) : NewActorbody would just beold(or rebuild the same record field-by-field with no transformation), the change is stable-compatible — delete the file.
Allowed without explicit migration (typical compatible changes):
- Changing mutability of a field (
vartoletor vice versa) - Adding variant constructors
- Changing
NattoInt(and other safe supertypes) - Other changes that satisfy Motoko stable subtyping
Require explicit migration:
- Removing any actor field the previous version exposes — consume it in
OldActor, omit fromNewActor - Promoting to
Anyor other lossy supertypes - Renaming fields (map old to new in migration)
- Non-trivial transformations (e.g.
InttoFloat, restructuring records) - Adding new stable fields (initial values must come from the migration; no inline initializers allowed)
- Introducing stable state for the first time (the migration supplies the initial values; the actor declares stable fields with types only, no inline initializers)
2. Explicit migration (migration function)
Use when the new state shape or types are not a simple compatible extension of the old.
Add a NEW timestamped file to src/backend/migrations/; the chain replays automatically.
Rules:
- Migration function type:
(old : OldActor) : NewActor. Both record types must be stable (no shared/local functions; use primitive types, records, variants, Option, Map, List, etc.). - Domain
OldActor: record of old stable fields (names and types as in the previous version). If the retired actor is missing a field the domain expects, the upgrade traps and is aborted. - Codomain
NewActor: record of new stable fields; each field must exist in the new actor with the same name and a supertype of the codomain type. Usevar x = ...orx = ...in the output to match the actor’svarvslet. - On fresh install, the entire chain replays in order starting from an empty actor (
OldActor = {}for the first migration); on upgrade, only entries newer than the deployed tail run. Exception: in a project converted from legacy persistence the first file’sOldActoris the pre-conversion stable shape, not{}— leave it alone (seetroubleshooting-motoko-migrations). - Each
NewActorfield’s value comes from the migration body. The actor body has no initializers in enhanced mode. - List every stable field in both
OldActorandNewActor(including unchanged ones). A field inOldActorbut not inNewActoris treated as an explicit discard (possible data loss). - If the migration function traps, the upgrade is aborted and the canister remains on the old version. Keep the migration pure and free of operations that can trap unexpectedly.
Multi-step upgrades (e.g. v1 to v2 to v3): Each upgrade step has one migration from the previously deployed version. The next version can use a new migration (or none if the change is stable-compatible).
Authoring a migration
Pick a bare UTC-timestamp filename (YYYYMMDD_HHMMSS.mo, no suffix) that sorts after every existing entry, then write the module:
module {
type OldActor = { /* old stable fields, inlined */ };
type NewActor = { /* new stable fields */ };
public func migration(old : OldActor) : NewActor {
{ /* produce new fields from old */ };
};
};
- Must be a
moduleexportingpublic func migration(old : OldActor) : NewActor(the function name is required — the chain runner discovers it by name). - Both
OldActorandNewActormust be inlined in the file — no project imports.
Actor body: types only, no initializers
Stable vars are declared with types, no initial values. Transient let/var fields use initializers as usual.
actor {
let tasks : Map.Map<Text, Task>;
var nextId : Nat;
transient var cache = List.empty<Text>();
};
Initial values come from the migration chain. When you introduce stable state for the first time, write a migration whose OldActor = {} and NewActor enumerates every stable field declared in main.mo. The migration body must produce a value for each. Missing fields surface as compatibility warnings and break subsequent upgrades.
Examples from component / extension skills may show inline initializers like
let accessControlState = AccessControl.initState();orlet users = Map.empty<Principal, User>();directly in the actor body. That pattern is for projects WITHOUT enhanced migration. Under enhanced migration it is a compile error (M0014, M0250). Treat such examples as state-shape hints only: copy the field name and type into your actor (without initializer), and move the initializer expression into the migration function’sNewActoroutput.
Stable-Compatible (definition)
Old signature is stable-compatible with new if, for every stable field id : T in the old signature:
- The new signature has a stable field
id : Usuch that T is a stable subtype of U (the old value can be used as the new field’s initial value without loss of data). - The new signature may include additional fields not present in the old one (each supplied by a migration).
- Matching fields may differ in mutability (
varvs non-var).
So: you can widen types (e.g. Nat to Int) and change mutability without an explicit migration. You cannot remove fields, rename them, or narrow types. Adding a stable field always requires a migration: inline initializers are not allowed, so the new field’s initial value must come from a migration file’s NewActor.
Patterns
Add record field with default
Use record with newField = value to add a field to every element:
// Add required field zipCode to each taxpayer
old.taxPayers.map<Nat, OldTaxPayer, NewTaxPayer>(
func(_id, oldTaxPayer) { { oldTaxPayer with zipCode = "" } }
);
For a new optional field, set it to null:
{ task with var status = task.status; var assignee = null : ?Principal }
Add or extend variant type
Adding a variant tag – cast the old value to the new variant type:
var status = bounty.status : NewTaskStatus // OldTaskStatus had #open;#claimed;#completed; New adds #expired
Replacing a Bool with a variant:
var status = if (task.completed) { #completed } else { #pending };
Map over a collection
Transform each value in a Map with map<K, OldV, NewV>(func(key, oldVal) { ... }):
let todos = old.todos.map<TaskID, OldTask, NewTask>(
func(_, task) {
{
task with
due = 0;
var status = if (task.completed) { #completed } else { #pending };
};
}
);
{ var todos; var nextTaskId = old.nextTaskId };
Add new top-level stable field (computed from old state)
Include the new field in the migration output and compute it from old fields:
let times = List.repeat<Time.Time>(0, old.messages.size());
{ var messages = old.messages; var times };
Restructure: change shape of state
When the new state has a different structure, provide a transformation function from the old field type to the new one and implement it according to the application’s migration logic. Build the new value in the migration from the old state and return a record that matches NewActor.
Change type of a field
Rename/conceptual change – e.g. artist : Text to artists : Set.Set<Text>:
{ oldPainting with artists = Set.singleton<Text>(oldPainting.artist) }
Rename or change type of a single field
Input record uses old name/type, output record uses new name/type:
// src/backend/migrations/YYYYMMDD_HHMMSS.mo
import Int "mo:core/Int";
module {
type OldActor = { var state : Int };
type NewActor = { var newState : Float };
public func migration(old : OldActor) : NewActor {
{ var newState = old.state.toFloat() };
};
};
Drop a field intentionally
Consume it in the migration input but do not include it in the output. Expect a compiler warning; ensure the loss is intentional.
Add a new field
Add the field to main.mo with type only (no initializer) AND add it to the next migration’s NewActor with a value computed from old fields or a constant. The actor body cannot supply the initial value under enhanced migration.
Migration-Time Semantics
- The migration function runs once at upgrade time, in the upgrade context. Values like
Time.now()in the migration body are the time of the upgrade, not of each original record creation. - Do not use constants or magic values in migrations; derive defaults from the migration input or from well-defined application types.
Compatibility and Tooling
The upgrade safety check compares the new actor body against the last deployed stable signature: a .most file (an encoded snapshot of the stable signature — field names and types) referenced from mops.toml under [canisters.<name>.check-stable]. When that is configured, mops check --fix picks it up automatically. Hosted platforms typically wire this up for you and keep the previous .most alongside the project.
For the check to pass: the new migration’s OldActor must match the previously deployed signature, and its NewActor must match (or be a stable supertype of) the new actor body.
To derive OldActor deterministically: your OldActor equals the NewActor of the file that precedes yours in src/backend/migrations/ (lex-order), or {} if yours is the first file in the chain. {} applies only when the chain starts from an empty canister — a project converted from legacy persistence starts from its pre-conversion stable shape instead, and its existing first file already reflects that.
Checklist for Upgrades
- Decide: implicit (compatible change) vs explicit (new migration file)
- At most ONE new migration file per build. Before creating a file, check
src/backend/migrations/— if a migration was already added in an earlier phase of this build, edit it instead of adding a second - If explicit: pick a bare UTC-timestamp filename (
YYYYMMDD_HHMMSS.mo, no suffix) that sorts after every existing file; do not encode the change in the name - Set
OldActorto theNewActorof the file that precedes yours insrc/backend/migrations/(lex-order), or{}if yours is the first file in a project that started out with a chain. Never from currentmain.mo. Never the file’s ownNewActor. - When the directory is empty (init migration),
NewActormust list every stable field declared inmain.mo, with a value for each - Inline both
OldActor(with old types) andNewActor(with new types) — no project imports - Implement
public func migration(old : OldActor) : NewActor - Never modify or delete migration files that existed before this build started; edit (don’t duplicate) any migration this build already created
- Do not use preupgrade/postupgrade or
(with migration = ...)for data migration - Iterate on
mops check --fix(fast) until it passes — it verifies compilation and upgrade safety - Run
mops buildONCE at the end (slow) to compile the backend and produce the updated IDL bindings
Additional References
- Migration examples: See examples.md for minimal, self-contained examples (add field, map over collection, add optional field)
- When something does not add up: Load
troubleshooting-motoko-migrationsfor compatibility diagnostics you cannot explain, write failures on frozen files, projects converted from legacy persistence, and requests to remove the chain. Not needed on the normal path. - General Motoko development: Use
writing-motokofor language fundamentals, core library reference, and architecture patterns - mops tooling: Load
mops-cliformops.tomlconfiguration,mops check,mops build, and toolchain setup - Motoko Docs: Data persistence
- Motoko Docs: Compatibility