Troubleshooting Motoko Migrations
Reference for the failure modes of the mops-managed migration chain (migrations/).
Do not load this for routine work. Writing a migration, deciding implicit vs explicit, or naming a new file is covered by migrating-motoko-actors — that skill’s rules are sufficient for the normal path. Come here when:
- a compatibility or migration diagnostic does not match what you see in
main.mo - a write to a migration file fails, or you are tempted to rename/delete one
- the first file in the chain has a non-empty
OldActorand you are unsure whether that is a bug - the task asks you to remove migrations, restore inline initializers, or go back to
(with migration = ...) mops checkcomplains about a state shape that is not in the current source
How the runtime decides what to run
- The whole chain is compiled into the backend wasm. There are no separate migration artifacts — changing any migration file changes the wasm.
- Applied migrations are tracked by module name (the filename without
.mo) in the canister’s persistent metadata. - On upgrade, the runtime replays only the migrations that module name says have not been applied yet. Deploying onto a canister several versions behind replays all the missing steps in one upgrade — you never need intermediate deploys to “catch up”.
- On fresh install, every file in the chain replays in lexicographic order. Every migration ever added runs on every fresh install of the project, forever.
Two consequences drive most of the rules in this skill:
- Renaming a migration changes its identity. The runtime would treat the renamed file as never applied and replay it against state that no longer matches — or skip the work the old name recorded. Filenames are permanent.
- Editing an applied migration is invisible to a canister that already ran it. The runtime skips it by name, so an edited body never executes. A stable field you add there is never initialized, and the canister traps at runtime reaching for it (
IC0503, “missing migration var” class). Fix forward with a new migration instead.
What the checker compares against
mops check validates your chain against the deployed canister’s stable signature (its .most snapshot), not against local git history. So:
- Diagnostics can name fields that are no longer anywhere in your source — they come from the deployed version.
- The one-pending-migration limit counts migrations not yet deployed. Files that are already live do not count toward it.
- The stable-signature major in a
.mostheader identifies the persistence model:1classic,3legacy inline(with migration = ...),4enhanced migration chain. Once a project reaches4it never goes back (see EM is one-way).
Diagnostics you may hit
| Diagnostic | What it actually means | Action |
|---|---|---|
M0250 initialized stable field |
A stable actor field has an inline initializer, which the chain (not the actor body) must supply | Reduce it to a typed declaration; move the initializer expression into the pending migration’s NewActor |
M0254 / M0267 initial actor requires field |
main.mo declares a stable field that no migration supplies. M0267 is the same problem when the deployed signature cannot account for it either |
Add the field to the pending migration’s NewActor with a value computed from OldActor or a well-defined default |
M0170 compatibility error |
The new actor is not stable-compatible with the deployed signature | Add (or extend) the pending migration so the transformation is explicit |
M0255 stable signature downgrade |
The build would move the canister from an enhanced chain back to an older persistence model — usually because the chain or the mops migrations config was removed | Restore what was removed; there is no supported downgrade |
| Chain start does not match the baseline | The first migration’s OldActor does not describe the state the canister actually holds — the classic case is a converted project where the chain starts from the legacy shape, not {} |
See Converted projects; do not rewrite OldActor to {} |
M0014 effectful actor body (older toolchains) |
Initialization work sitting in the actor body | Keep the actor body static — see Static actor body |
IC0503 missing migration var (runtime, after deploy) |
A stable field never received a value — typically an edited or renamed applied migration, or a field added without a migration | Add a new migration that supplies the field; never repair by editing the old file |
IC0505 invalid custom section (install only, build and check pass) |
The chain’s stable-type history has outgrown the platform’s custom-section limit | Nothing local fixes it — stop adding migrations and report it; see Why chains must stay short |
Write failures on a migration file are not a tooling bug — see the next section.
Frozen migration files
On platforms that freeze deployed migrations, each one is made read-only after a successful deploy and write tools reject it by its mode bits.
- Do not
chmodit, delete it, rename it, or route around the write failure. The freeze encodes a runtime invariant, not a policy preference. - A migration created earlier in the current build is not frozen: edit that one rather than adding a second file for the same change.
- If the change you need belongs conceptually in a frozen migration, express it as a new migration that transforms the current state into the shape you want. History is append-only.
Converted (legacy → enhanced) projects
Some enhanced-migration projects were not created that way — they were converted in place from legacy inline-migration persistence, with live data preserved. In such a project:
- The first file in the chain is typically an identity migration whose
OldActoris the pre-conversion stable shape and whoseNewActorpasses the same data through unchanged. This is correct. Do not “simplify” it toOldActor = {}and do not re-seed the fields — that would discard live state. OldActor = {}is only right when the chain genuinely starts from an empty canister, i.e. the project used the chain from creation.- The pre-conversion history stays load-bearing: fresh installs of a converted project replay from the last legacy schema, not from
{}. Never try to prune, flatten, or “clean up” the chain to make it start at{}. - Diagnostics about a field that exists only in the pre-conversion shape are expected on the first migration and are handled by the platform’s build configuration. Do not restructure the chain to silence them.
A project can also inherit such a chain without having been converted itself — forking or remixing a converted project copies the chain as-is, first migration included. The rule is the same either way, and it is readable straight off the chain: a non-empty OldActor in the earliest file means state predates the chain, so that file is correct as written.
Protected configuration
Where a hosting platform owns the mops migrations config — the [canisters.<name>.migrations] section, its chain path, and check-limit — you cannot add, remove, or alter it, and the tooling will reject attempts.
- Never raise or drop
check-limitto clear a “too many pending migrations” error. The fix is always to fold the extra changes into the single pending migration this build already created. - The chain directory is whatever
chain = ...says. It is normallysrc/backend/migrations, but a project imported under a non-default canister name can differ. Read the mops config rather than assuming the path. - If the migrations section appears to be missing from a project that has a chain, do not re-add it yourself; report the inconsistency.
EM is one-way
Once a project uses the migration chain there is no supported path back to legacy persistence. Independent mechanisms enforce this:
- The persistence model is decided per project and is sticky — nothing re-evaluates it later.
- The compiler rejects a signature downgrade (
M0255), and a legacy wasm deployed over an enhanced canister traps at runtime. Signature versions only move forward. - Applied migrations are identified by module name, so history cannot be rewritten by editing or renaming files — and on platforms that freeze deployed migrations, it cannot be rewritten at all.
- Fresh installs of a converted project permanently depend on the pre-conversion history.
So if a task asks you to delete the migrations/ directory, restore inline initializers on stable fields, or reintroduce (with migration = ...), say plainly that it is not possible for this project and offer the forward-only equivalent: a new migration that reshapes state toward what the user actually wants.
Why chains must stay short
The one-migration-per-deployed-version rule is not bureaucratic. A long chain eventually becomes undeployable, and the failure is unrecoverable:
- The compiled wasm carries a custom section encoding the entire stable-type history. It grows with every migration and with the size of the stable types themselves, until it crosses the platform’s 1 MiB limit on those sections. With modest stable types that happens in the high hundreds of files; with richer types, proportionally sooner.
mops buildandmops checkstill succeed at that point — only the install is rejected (IC0505, invalid custom section). A green check is not evidence that the chain is deployable, and no redeploy or retry recovers it: the oversized section is baked into the artifact.- Every file replays on every fresh install, forever, inside a single message. Garbage collection only runs at message boundaries, so every intermediate state stays live for the whole replay — a data-heavy migration can exhaust the memory limit at a far smaller chain length than the section cap implies.
- Fresh installs recurse once per step, so call-stack depth is proportional to chain length.
- Wasm size and compile time grow with every step, and the
.mostsignature serializes the whole chain.
Nothing in the toolchain caps chain length, so the one-migration-per-version discipline is the only thing keeping a project away from that cliff: fold changes into the pending migration, keep stable types lean, and never add a migration whose body would just be old.
Static actor body
The actor body must not do work. Stable fields are typed declarations; their values come from the chain.
Older toolchains rejected any effectful actor body outright, and that compiler check has since been relaxed for transient bindings — but the rule stands regardless of whether the compiler complains, because top-level seeding re-runs on every upgrade and would overwrite live state.
Initialization belongs in exactly two places:
- a migration function, which runs once per canister at install/upgrade time, or
- idempotent logic invoked lazily during normal operation (e.g. inside an update method that checks whether the work is already done).
When you cannot resolve it
Do not create room to proceed by relaxing protected configuration, deleting or renaming history, or rewriting a frozen file. Those turn a build failure into data loss. If the chain and the deployed signature genuinely disagree in a way none of the above explains, stop and report the mismatch: which fields the checker names, what the latest migration’s NewActor declares, and what main.mo declares.
Related skills
migrating-motoko-actors— how to author migrations (the normal path)writing-motoko— language reference, actor and mixin rules, core library