Motoko

Motoko Actor Migrations

Motoko actor state migration and schema evolution using the mops-managed migration chain (a migrations/ directory of timestamped files). Use when upgrading canisters, changing actor field types, renaming or adding stable fields, deciding implicit vs explicit migration, or writing migration files — including one-shot changes. For unexplained compatibility diagnostics, frozen migration files, a non-empty first OldActor, projects converted from legacy persistence, or requests to remove the chain or revert to inline (with migration = ...), use troubleshooting-motoko-migrations.

Skill ID
migrating-motoko-actors
Category
Motoko
License
Apache-2.0
Compatibility
moc >= 1.11.2, core >= 2.5.0
Last updated
Source

Trust note.This page is a static, pre-rendered mirror ofdfinity/icskills/skills/migrating-motoko-actors/SKILL.md. The canonical source is the Git commit it was built from. Licensed Apache-2.0.

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 as chain = ... under [canisters.<name>.migrations] in mops.toml; an imported project using a non-default canister name may place it elsewhere — read mops.toml rather than assuming.)
  • At most one pending migration per build (check-limit = 1 in mops.toml). If this build already added a migration file, edit that file to fold in further changes instead of adding another. mops check compares the deployed .most baseline and names the latest pending file to fold into when the limit is exceeded. Where a hosting platform owns the migrations section and check-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 (no AddPriority, 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 ../types or any project module. The chain replays forever; a frozen migration that imported Types.Note becomes wrong the moment Note changes in an incompatible way.
  • mops check --fix automatically 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 check will not warn you. Fewer migrations are always better.

Identity body = no migration. If your (old : OldActor) : NewActor body would just be old (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 (var to let or vice versa)
  • Adding variant constructors
  • Changing Nat to Int (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 from NewActor
  • Promoting to Any or other lossy supertypes
  • Renaming fields (map old to new in migration)
  • Non-trivial transformations (e.g. Int to Float, 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. Use var x = ... or x = ... in the output to match the actor’s var vs let.
  • 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’s OldActor is the pre-conversion stable shape, not {} — leave it alone (see troubleshooting-motoko-migrations).
  • Each NewActor field’s value comes from the migration body. The actor body has no initializers in enhanced mode.
  • List every stable field in both OldActor and NewActor (including unchanged ones). A field in OldActor but not in NewActor is 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 module exporting public func migration(old : OldActor) : NewActor (the function name is required — the chain runner discovers it by name).
  • Both OldActor and NewActor must 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(); or let 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’s NewActor output.

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 : U such 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 (var vs 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 OldActor to the NewActor of the file that precedes yours in src/backend/migrations/ (lex-order), or {} if yours is the first file in a project that started out with a chain. Never from current main.mo. Never the file’s own NewActor.
  • When the directory is empty (init migration), NewActor must list every stable field declared in main.mo, with a value for each
  • Inline both OldActor (with old types) and NewActor (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 build ONCE 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-migrations for 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-motoko for language fundamentals, core library reference, and architecture patterns
  • mops tooling: Load mops-cli for mops.toml configuration, mops check, mops build, and toolchain setup
  • Motoko Docs: Data persistence
  • Motoko Docs: Compatibility