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.

Storage recommendations

Choose the right storage type

Memory typeMax sizePersists across upgradesBest for
Heap4 GiBNo (Rust) / Yes (Motoko persistent actor)Frequently accessed data, caches, ephemeral computation
Stable500 GiBYesAll important data, large datasets, anything that must survive upgrades

The practical rule: use stable structures directly for any data that matters. Avoid relying on pre_upgrade / post_upgrade hooks to serialize heap data to stable memory. Serializing large heap state during an upgrade can hit the instruction limit and trap, leaving the canister on the old code. Data in stable structures is already in stable memory from the first write — no serialization step required on upgrade.

For Motoko, persistent actor makes all let and var declarations persistent automatically. There is no need to choose manually between heap and stable memory.

Language-specific recommendations

Choose efficient data structures.

The mo:core library provides stable-friendly, performant data structures. Use these in preference to the legacy mo:base equivalents:

Use casemo:core typeReplaces (mo:base)
Key-value mapMapHashMap, TrieMap, Trie, RBTree
Dynamic sequenceListBuffer
Double-ended queueQueueDeque
Ordered mappure/MapOrderedMap
Ordered setpure/SetOrderedSet

Map avoids the automatic resizing overhead that HashMap incurs on growth. List handles dynamic sequences without the fragile array-copy pattern of Buffer.

Prefer Blob over [Nat8] for binary data.

Blob is 4× more compact than [Nat8] and produces significantly less GC pressure. Use Blob for binary assets, cryptographic values, and anywhere you would send or receive vec nat8 in Candid. Store large Blobs in stable memory.

Use compacting-gc for append-only workloads (classical persistence only).

If your canister grows the heap by appending data without frequent deletions, the --compacting-gc flag allows the GC to handle larger heaps and reduces the cost of copying large, stationary objects. Enable it in icp.yaml under canister build args. Note: --compacting-gc applies only to the legacy classical persistence mode (--legacy-persistence); it is not used with the default enhanced orthogonal persistence.

Implement state backup mechanisms

Even with stable memory, consider implementing explicit backup mechanisms for state that would be catastrophic to lose. This protects against:

  • Accidental reinstall (which wipes stable memory)
  • Bugs in upgrade hooks that corrupt the stable layout
  • Application-level errors that require rollback

Common approaches include exporting a snapshot of canister state to a Blob that can be stored externally, or using canister snapshots to checkpoint state before an upgrade.

Transaction history storage

If your application needs to maintain a history of transactions or events, avoid storing unbounded logs in the same canister as your main application state. Options:

  • Dedicated logging canister. A separate canister that accepts append-only log entries reduces load on the main canister and keeps the history size from affecting upgrade cost.
  • StableLog (Rust). For canisters that can accommodate history growth, StableLog from ic-stable-structures provides an append-only log directly in stable memory.
  • External history services. Services like CAP maintain transaction provenance records that integrate with explorers and wallets, which is useful for digital asset standards compliance.

Be aware that inter-canister calls to a logging service add latency and cycle cost. Size the logging approach to the transaction volume you expect.