Motoko

Motoko Enhanced Migration

Enhanced multi-step migration for Motoko actors using a migrations/ directory and --enhanced-migration flag. Use when upgrading canister state across multiple deployments, writing migration files, changing actor field types, or managing a migration chain. For a single one-shot migration, use migrating-motoko instead.

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

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

Enhanced Multi-Migration

Manage canister state evolution through a chain of migration modules. Each migration captures one logical change (add, rename, drop, transform a field) and the compiler verifies the entire chain is consistent.

When to Use

  • Adding, removing, or renaming persistent actor fields
  • Changing a field’s type
  • Restructuring state across canister upgrades
  • Project has [canisters.<name>.migrations] configured in mops.toml

Critical Rules

  • Never use stable keyword, preupgrade/postupgrade, or inline (with migration = ...)
  • Actor variables are declared without initializers — values come from the migration chain
  • The actor body must be static (no top-level side effects except <system> calls like timers)
  • Each migration file exports public func migration({...}) : {...}
  • Files are applied in lexicographic order — use timestamp prefixes

Directory Layout

backend/
├── main.mo
├── types.mo
├── lib/
├── mixins/
└── migrations/
    ├── 20250101_000000_Init.mo
    ├── 20250315_120000_AddProfile.mo
    └── 20250601_090000_RenameField.mo

Actor Syntax

With enhanced migration, actor variables have no initializer:

actor {
  var name : Text;       // value comes from migration chain
  var balance : Nat;     // likewise
  let frozen : Bool;     // let bindings can also be uninitialized

  public func greet() : async Text {
    "Hello, " # name # "! Balance: " # debug_show balance;
  };
};

Migration Module Structure

Each migration module takes a record of input fields and returns a record of output fields:

// migrations/20250101_000000_Init.mo
module {
  public func migration(_ : {}) : { name : Text; balance : Nat } {
    { name = ""; balance = 0 }
  }
}

Input / Output Field Semantics

Field appears inEffect
Input and outputField is transformed (old value read, new value produced)
Output onlyNew field added to state
Input onlyField consumed and removed from state
NeitherField carried through unchanged

Given state {a : Nat; b : Text; c : Bool} and migration:

module {
  public func migration(old : { a : Nat; b : Text }) : { a : Int; d : Float } {
    { a = old.a; d = 1.0 }
  }
}
  • a: transformed Nat → Int
  • b: consumed (removed)
  • c: carried through unchanged
  • d: newly introduced
  • Result: {a : Int; c : Bool; d : Float}

Common Patterns

Initialize state (first migration, always required)

// migrations/20250101_000000_Init.mo
module {
  public func migration(_ : {}) : { count : Nat; header : Text } {
    { count = 0; header = "default" }
  }
}

Add a field

// migrations/20250201_000000_AddEmail.mo
module {
  public func migration(_ : {}) : { email : Text } {
    { email = "" }
  }
}

Add an optional field

module {
  public func migration(_ : {}) : { assignee : ?Principal } {
    { assignee = null }
  }
}

Change a field’s type

// migrations/20250301_000000_CountToInt.mo
module {
  public func migration(old : { count : Nat }) : { count : Int } {
    { count = old.count }
  }
}

Rename a field

// migrations/20250401_000000_RenameHeader.mo
module {
  public func migration(old : { header : Text }) : { title : Text } {
    { title = old.header }
  }
}

Remove a field

// migrations/20250501_000000_DropEmail.mo
module {
  public func migration(_ : { email : Text }) : {} {
    {}
  }
}

Transform data (split a field)

// migrations/20250601_000000_SplitName.mo
import Text "mo:core/Text";

module {
  public func migration(old : { name : Text }) : { firstName : Text; lastName : Text } {
    let parts = old.name.split(#char ' ');
    let first = switch (parts.next()) { case (?f) f; case (null) "" };
    let last = switch (parts.next()) { case (?l) l; case (null) "" };
    { firstName = first; lastName = last }
  }
}

Bool to variant

module {
  public func migration(old : { var completed : Bool }) : { var status : { #pending; #completed } } {
    { var status = if (old.completed) { #completed } else { #pending } }
  }
}

Map over a collection

import Map "mo:core/Map";

module {
  type OldTask = { id : Nat; title : Text; var completed : Bool };
  type NewTask = { id : Nat; title : Text; var status : { #pending; #completed } };

  public func migration(old : { var tasks : Map.Map<Nat, OldTask> })
    : { var tasks : Map.Map<Nat, NewTask> } {
    let tasks = old.tasks.map<Nat, OldTask, NewTask>(
      func(_, task) {
        {
          id = task.id;
          title = task.title;
          var status = if (task.completed) { #completed } else { #pending };
        }
      }
    );
    { var tasks }
  }
}

Add field to each record in a Map

import Map "mo:core/Map";

module {
  type OldUser = { name : Text; email : Text };
  type NewUser = { name : Text; email : Text; bio : Text };

  public func migration(old : { users : Map.Map<Nat, OldUser> })
    : { users : Map.Map<Nat, NewUser> } {
    let users = old.users.map<Nat, OldUser, NewUser>(
      func(_, u) { { u with bio = "" } }
    );
    { users }
  }
}

How Migrations Compose

Migrations form a chain. The compiler verifies each migration’s input is compatible with the state produced by all preceding migrations.

MigrationInputOutputEffect
Init{}{name : Text; balance : Nat}Initializes both fields
AddProfile{}{profile : Text}Adds a new field
RenameField{name : Text}{displayName : Text}Renames name → displayName

After the full chain: {displayName : Text; balance : Nat; profile : Text}. The actor must declare fields compatible with this final state.

Lifecycle Example: Todo App

Shows how patterns combine across four deployments.

// migrations/20250101_000000_Init.mo
module {
  public func migration(_ : {}) : { var nextId : Nat } {
    { var nextId = 0 }
  }
}
// migrations/20250201_000000_AddTasks.mo
import Map "mo:core/Map";
module {
  type Task = { id : Nat; text : Text; completed : Bool };
  public func migration(_ : {}) : { tasks : Map.Map<Nat, Task> } {
    { tasks = Map.empty<Nat, Task>() }
  }
}
// migrations/20250301_000000_TaskStatus.mo — transform Bool → variant
import Map "mo:core/Map";
module {
  type OldTask = { id : Nat; text : Text; completed : Bool };
  type NewTask = { id : Nat; text : Text; status : { #pending; #inProgress; #completed } };
  public func migration(old : { tasks : Map.Map<Nat, OldTask> })
    : { tasks : Map.Map<Nat, NewTask> } {
    let tasks = old.tasks.map<Nat, OldTask, NewTask>(
      func(_, task) {
        { id = task.id; text = task.text;
          status = if (task.completed) #completed else #pending }
      }
    );
    { tasks }
  }
}
// migrations/20250401_000000_AddDueDate.mo — add field to each record
import Map "mo:core/Map";
module {
  type Status = { #pending; #inProgress; #completed };
  type OldTask = { id : Nat; text : Text; status : Status };
  type NewTask = { id : Nat; text : Text; status : Status; due : Int };
  public func migration(old : { tasks : Map.Map<Nat, OldTask> })
    : { tasks : Map.Map<Nat, NewTask> } {
    let tasks = old.tasks.map<Nat, OldTask, NewTask>(
      func(_, task) { { task with due = 0 } }
    );
    { tasks }
  }
}

Final state: { var nextId : Nat; tasks : Map.Map<Nat, { id : Nat; text : Text; status : { #pending; #inProgress; #completed }; due : Int }> }

Runtime Behavior

  • On fresh deploy: all migrations run in order
  • On upgrade: only not-yet-applied migrations run (already-applied are skipped)
  • Fast-forward: safe to skip intermediate deployments — all unapplied migrations run sequentially
  • If a migration traps, the upgrade is aborted and the canister stays on the old version

mops.toml Setup

[moc]
args = ["--default-persistent-actors"]

[canisters.backend]
main = "src/backend/main.mo"

[canisters.backend.migrations]
chain = "src/backend/migrations"

When [canisters.<name>.migrations] is configured, mops auto-injects --enhanced-migration into check/build/check-stable. Do not add --enhanced-migration to [canisters.<name>].args — mops will error.

--enhanced-orthogonal-persistence is on by default.

Then mops check --fix and mops build work as usual. Add new migration files directly under migrations/ with timestamp prefixes.

Restrictions

  • Cannot combine --enhanced-migration with inline (with migration = ...)
  • Requires enhanced orthogonal persistence
  • Actor variables must not have initializers
  • Actor body must be static (no top-level side effects except <system> calls)
  • State after each migration must be compatible with the next migration’s input
  • Final state must match the actor’s declared fields
  • Fields in last migration’s output not declared in the actor are rejected

Checklist

  • migrations/ directory exists next to actor source
  • First migration initializes all fields (Init.mo with empty input)
  • Files named with timestamp prefixes for correct ordering
  • Each file exports public func migration({...}) : {...}
  • Actor variables declared without initializers
  • [canisters.<name>.migrations] configured in mops.toml (mops injects --enhanced-migration)
  • Run mops check --fix to verify chain consistency
  • Run mops build to compile

Additional References

  • Load motoko for general Motoko language reference and mo:core APIs
  • Load migrating-motoko for inline migration without --enhanced-migration
  • Load mops-cli for mops check, mops build, and toolchain setup