For AI agents: Documentation index at /llms.txt

Skip to content

Data Persistence

Canister state lives in two places: heap memory and stable memory (persistent, survives upgrades). In Rust and most languages, heap memory is wiped on upgrade: any data you care about must be stored in stable memory. In Motoko, the persistent actor pattern automatically preserves all actor state across upgrades without any additional work.

This guide shows how to store data durably in both Motoko and Rust. For a conceptual explanation of why stable memory works this way, see Orthogonal Persistence.

Store data durably

Use persistent actor. All let and var declarations inside the actor body are automatically persisted across upgrades. No stable keyword, no upgrade hooks.

import Map "mo:core/Map";
import Nat "mo:core/Nat";
import Text "mo:core/Text";
import Time "mo:core/Time";
persistent actor {
// Custom type: defined inside the actor body
type User = {
id : Nat;
name : Text;
created : Int;
};
// Automatically persisted across upgrades: no "stable" keyword needed
let users = Map.empty<Nat, User>();
var userCounter : Nat = 0;
// Transient data: resets to 0 on every upgrade
transient var requestCount : Nat = 0;
public func addUser(name : Text) : async Nat {
let id = userCounter;
Map.add(users, Nat.compare, id, {
id;
name;
created = Time.now();
});
userCounter += 1;
requestCount += 1;
id
};
public query func getUser(id : Nat) : async ?User {
Map.get(users, Nat.compare, id)
};
public query func getUserCount() : async Nat {
Map.size(users)
};
// Resets to 0 after every upgrade: use transient for ephemeral state
public query func getRequestCount() : async Nat {
requestCount
};
}

Key rules:

  • let for collections (Map, List, Set): auto-persisted, no serialization needed
  • var for simple values (Nat, Text, Bool): auto-persisted
  • transient var for caches or counters that should reset on upgrade
  • No pre_upgrade / post_upgrade hooks needed. The runtime handles persistence
  • Do not write stable let or stable var: redundant in persistent actor and produces compiler warnings

mops.toml:

[package]
name = "my-project"
version = "0.1.0"
[dependencies]
core = "2.0.0"

Schema evolution

When upgrading a Motoko canister, the type of every persistent field must be compatible with its stored value. Violating this causes the upgrade to trap. The canister continues running on the old Wasm with its data intact, but cannot be upgraded until the type conflict is resolved.

Safe changes (always OK):

  • Add new let or var fields with initial values
  • Add new optional record fields (e.g., change { name : Text } to { name : Text; email : ?Text })

Unsafe changes (will trap on upgrade):

  • Remove or rename a persistent field
  • Change a field’s type (e.g., NatInt)
  • Change a non-optional field to a different type

Idempotency for safe data mutation

When an update call’s result is unknown (network interruption, ingress expiry), callers may retry. Without idempotency, retries can cause double-writes, double-spends, or duplicate records. Two patterns handle this:

Sequence numbers

Track a per-caller counter. A call is only accepted if it carries the next expected sequence number:

import Map "mo:core/Map";
import Nat "mo:core/Nat";
import Principal "mo:core/Principal";
persistent actor {
var callerSeq = Map.empty<Principal, Nat>();
public shared(msg) func transferWithSeq(amount : Nat, seq : Nat) : async Bool {
let caller = msg.caller;
let expected = switch (Map.get(callerSeq, Principal.compare, caller)) {
case null 0;
case (?n) n;
};
if (seq != expected) return false; // reject out-of-order or duplicate calls
// ... perform transfer ...
Map.add(callerSeq, Principal.compare, caller, seq + 1);
true
};
}

Best for low-throughput, per-account flows (similar to Ethereum nonces). Limits concurrency to one in-flight call per caller.

ID deduplication

Callers attach a unique ID per operation. The canister rejects duplicates within a time window:

import Map "mo:core/Map";
import Text "mo:core/Text";
import Time "mo:core/Time";
persistent actor {
type DedupeEntry = { executed_at : Int };
let executed = Map.empty<Text, DedupeEntry>();
let WINDOW_NS : Int = 24 * 60 * 60 * 1_000_000_000; // 24 hours in nanoseconds
public func transferWithId(amount : Nat, idempotency_key : Text) : async Bool {
let now = Time.now();
switch (Map.get(executed, Text.compare, idempotency_key)) {
case (?entry) {
if (now - entry.executed_at < WINDOW_NS) return true; // already done
};
case null {};
};
// ... perform transfer ...
Map.add(executed, Text.compare, idempotency_key, { executed_at = now });
true
};
}

Supports higher throughput and concurrent callers. Requires bounded storage: expire entries after the deduplication window.

Verify persistence across upgrades

The definitive test: deploy, write data, upgrade, confirm data survived.

Method names are camelCase in Motoko:

Terminal window
icp network start -d
icp deploy backend
# Write some data
icp canister call backend addUser '("Alice")'
icp canister call backend addUser '("Bob")'
# Record the count
icp canister call backend getUserCount '()'
# Returns: (2 : nat)
# Upgrade the canister (redeploy with code change)
icp deploy backend
# Data must still be there
icp canister call backend getUserCount '()'
# Must still return: (2 : nat)
icp canister call backend getUser '(0)'
# Returns: (opt record { id = 0 : nat; name = "Alice"; ... })
# Transient state resets
icp canister call backend getRequestCount '()'
# Returns: (0 : nat): expected, transient var resets on upgrade

If the count drops to 0 after upgrade, the data is not in stable memory. Review your storage declarations.