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:
letfor collections (Map,List,Set): auto-persisted, no serialization neededvarfor simple values (Nat,Text,Bool): auto-persistedtransient varfor caches or counters that should reset on upgrade- No
pre_upgrade/post_upgradehooks needed. The runtime handles persistence - Do not write
stable letorstable var: redundant inpersistent actorand produces compiler warnings
mops.toml:
[package]name = "my-project"version = "0.1.0"
[dependencies]core = "2.0.0"Rust canisters use ic-stable-structures for persistent storage. The MemoryManager partitions stable memory into virtual memories, each backing a separate data structure. Data lives in stable memory from the start. No serialization on upgrade.
Cargo.toml:
[package]name = "stable_memory_backend"version = "0.1.0"edition = "2021"
[lib]crate-type = ["cdylib"]
[dependencies]ic-cdk = "0.19"ic-stable-structures = "0.7"candid = "0.10"serde = { version = "1", features = ["derive"] }ciborium = "0.2"Implementing Storable for custom types:
StableBTreeMap keys must implement Storable + Ord, values must implement Storable. Primitive types (u64, bool, String, Vec<u8>, Principal) already implement Storable. For custom structs, implement it manually using CBOR serialization:
use ic_stable_structures::storable::{Bound, Storable};use candid::CandidType;use serde::{Deserialize, Serialize};use std::borrow::Cow;
#[derive(CandidType, Serialize, Deserialize, Clone)]struct User { id: u64, name: String, created: u64,}
impl Storable for User { // Prefer Unbounded: avoids breakage when adding new fields. // Bounded requires a fixed max_size; if the encoded size of a value // exceeds max_size after a schema change, writes will trap. // Existing stored data is unaffected, but no new or updated records // can be written until the type fits within the declared max_size. const BOUND: Bound = Bound::Unbounded;
fn to_bytes(&self) -> Cow<'_, [u8]> { let mut buf = vec![]; ciborium::into_writer(self, &mut buf).expect("Failed to encode User"); Cow::Owned(buf) }
fn from_bytes(bytes: Cow<'_, [u8]>) -> Self { ciborium::from_reader(bytes.as_ref()).expect("Failed to decode User") }}MemoryManager and stable structures:
use ic_stable_structures::{ memory_manager::{MemoryId, MemoryManager, VirtualMemory}, storable::{Bound, Storable}, DefaultMemoryImpl, StableBTreeMap, StableCell,};use ic_cdk::{init, post_upgrade, query, update};use candid::CandidType;use serde::{Deserialize, Serialize};use std::borrow::Cow;use std::cell::RefCell;
type Memory = VirtualMemory<DefaultMemoryImpl>;
// Each structure gets its own MemoryId: NEVER reuse IDs across structuresconst USERS_MEM_ID: MemoryId = MemoryId::new(0);const COUNTER_MEM_ID: MemoryId = MemoryId::new(1);
thread_local! { static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> = RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));
static USERS: RefCell<StableBTreeMap<u64, User, Memory>> = RefCell::new(StableBTreeMap::init( MEMORY_MANAGER.with(|m| m.borrow().get(USERS_MEM_ID)) ));
// StableCell for a single value (counter, config, etc.) static COUNTER: RefCell<StableCell<u64, Memory>> = RefCell::new(StableCell::init( MEMORY_MANAGER.with(|m| m.borrow().get(COUNTER_MEM_ID)), 0u64, ).expect("Failed to init counter"));}
#[init]fn init() { // One-time initialization: stable structures auto-initialize from above}
#[post_upgrade]fn post_upgrade() { // Stable structures auto-restore: no deserialization needed here. // Re-initialize timers or other transient state if needed.}
#[update]fn add_user(name: String) -> u64 { let id = COUNTER.with(|c| { let mut cell = c.borrow_mut(); let current = *cell.get(); cell.set(current + 1).expect("Counter update failed"); current });
USERS.with(|users| { users.borrow_mut().insert(id, User { id, name, created: ic_cdk::api::time(), }); });
id}
#[query]fn get_user(id: u64) -> Option<User> { USERS.with(|users| users.borrow().get(&id))}
#[query]fn get_user_count() -> u64 { USERS.with(|users| users.borrow().len())}
ic_cdk::export_candid!();Key rules:
- Each structure gets a unique
MemoryId: reusing IDs corrupts both structures StableBTreeMapfor keyed collections; keys needStorable + OrdStableCellfor single values (counters, config flags)StableLogfor append-only logs: requires twoMemoryIds (index + data)thread_local! { RefCell<StableBTreeMap<...>> }is the correct pattern:RefCellwraps the stable structure, not a heapHashMap- No
pre_upgrade/post_upgradeserialization needed: data is already in stable memory
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
letorvarfields 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.,
Nat→Int) - Change a non-optional field to a different type
When using more than one stable structure, give each a unique MemoryId. StableLog requires two memory regions (index + data).
This example extends the snippet above: it reuses the same Memory type alias, MemoryManager, DefaultMemoryImpl, RefCell, and User struct, and adds Post and AUDIT_LOG:
use ic_stable_structures::{ memory_manager::{MemoryId, MemoryManager, VirtualMemory}, DefaultMemoryImpl, StableBTreeMap, StableCell, StableLog,};use candid::CandidType;use serde::{Deserialize, Serialize};use std::cell::RefCell;
type Memory = VirtualMemory<DefaultMemoryImpl>;
#[derive(CandidType, Serialize, Deserialize, Clone)]struct Post { id: u64, content: String,}
// Assign one MemoryId per structure: never reuseconst USERS_MEM_ID: MemoryId = MemoryId::new(0);const POSTS_MEM_ID: MemoryId = MemoryId::new(1);const COUNTER_MEM_ID: MemoryId = MemoryId::new(2);const LOG_INDEX_MEM_ID: MemoryId = MemoryId::new(3); // StableLog needs twoconst LOG_DATA_MEM_ID: MemoryId = MemoryId::new(4);
thread_local! { static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> = RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));
static USERS: RefCell<StableBTreeMap<u64, User, Memory>> = RefCell::new(StableBTreeMap::init( MEMORY_MANAGER.with(|m| m.borrow().get(USERS_MEM_ID)) ));
static POSTS: RefCell<StableBTreeMap<u64, Post, Memory>> = RefCell::new(StableBTreeMap::init( MEMORY_MANAGER.with(|m| m.borrow().get(POSTS_MEM_ID)) ));
static COUNTER: RefCell<StableCell<u64, Memory>> = RefCell::new(StableCell::init( MEMORY_MANAGER.with(|m| m.borrow().get(COUNTER_MEM_ID)), 0u64, ).expect("Failed to init counter"));
static AUDIT_LOG: RefCell<StableLog<Vec<u8>, Memory, Memory>> = RefCell::new(StableLog::init( MEMORY_MANAGER.with(|m| m.borrow().get(LOG_INDEX_MEM_ID)), MEMORY_MANAGER.with(|m| m.borrow().get(LOG_DATA_MEM_ID)), ).expect("Failed to init audit log"));}Anti-pattern: pre_upgrade serialization
Avoid serializing heap data to stable memory in pre_upgrade hooks. This pattern is fragile and will brick the canister under load:
// DO NOT DO THIS#[pre_upgrade]fn pre_upgrade() { // If STATE is large, this hits the instruction limit and traps. // A trapped pre_upgrade prevents the upgrade from completing: // the canister is stuck on the old code. Recovery is possible via // the skip_pre_upgrade flag (which bypasses the hook at the cost of // losing any state it would have serialized), but it's an emergency // measure. Avoid this pattern entirely. let state = STATE.with(|s| s.borrow().clone()); ic_cdk::storage::stable_save((state,)).unwrap();}
#[post_upgrade]fn post_upgrade() { let (state,) = ic_cdk::storage::stable_restore().unwrap(); STATE.with(|s| *s.borrow_mut() = state);}Use StableBTreeMap and other stable structures instead. Data lives in stable memory from the start, so no serialization step is needed on upgrade.
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};
}use ic_stable_structures::{StableBTreeMap, memory_manager::{MemoryId, MemoryManager, VirtualMemory}, DefaultMemoryImpl};use ic_cdk::{caller, update};use candid::Principal;use std::cell::RefCell;
type Memory = VirtualMemory<DefaultMemoryImpl>;
thread_local! { static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> = RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));
static CALLER_SEQ: RefCell<StableBTreeMap<Principal, u64, Memory>> = RefCell::new(StableBTreeMap::init( MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(10))) ));}
#[update]fn transfer_with_seq(amount: u64, seq: u64) -> bool { let caller = caller(); CALLER_SEQ.with(|s| { let mut map = s.borrow_mut(); let expected = map.get(&caller).unwrap_or(0); if seq != expected { return false; // reject out-of-order or duplicate calls } // ... perform transfer ... map.insert(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};
}use ic_stable_structures::{StableBTreeMap, memory_manager::{MemoryId, MemoryManager, VirtualMemory}, DefaultMemoryImpl};use ic_stable_structures::storable::{Bound, Storable};use ic_cdk::{api::time, update};use std::borrow::Cow;use std::cell::RefCell;
type Memory = VirtualMemory<DefaultMemoryImpl>;
const WINDOW_NS: u64 = 24 * 60 * 60 * 1_000_000_000; // 24 hours in nanoseconds
thread_local! { static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> = RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));
static EXECUTED: RefCell<StableBTreeMap<String, u64, Memory>> = RefCell::new(StableBTreeMap::init( MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(11))) ));}
#[update]fn transfer_with_id(amount: u64, idempotency_key: String) -> bool { let now = time(); EXECUTED.with(|s| { let mut map = s.borrow_mut(); if let Some(executed_at) = map.get(&idempotency_key) { if now - executed_at < WINDOW_NS { return true; // already done } } // ... perform transfer ... map.insert(idempotency_key, 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:
icp network start -dicp deploy backend
# Write some dataicp canister call backend addUser '("Alice")'icp canister call backend addUser '("Bob")'
# Record the counticp canister call backend getUserCount '()'# Returns: (2 : nat)
# Upgrade the canister (redeploy with code change)icp deploy backend
# Data must still be thereicp 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 resetsicp canister call backend getRequestCount '()'# Returns: (0 : nat): expected, transient var resets on upgradeMethod names are snake_case in Rust:
icp network start -dicp deploy backend
# Write some dataicp canister call backend add_user '("Alice")'icp canister call backend add_user '("Bob")'
# Record the counticp canister call backend get_user_count '()'# Returns: (2 : nat64)
# Upgrade the canister (redeploy with code change)icp deploy backend
# Data must still be thereicp canister call backend get_user_count '()'# Must still return: (2 : nat64)
icp canister call backend get_user '(0 : nat64)'# Returns: (opt record { id = 0 : nat64; name = "Alice"; created = ... })If the count drops to 0 after upgrade, the data is not in stable memory. Review your storage declarations.
Related
- Orthogonal Persistence: conceptual explanation of heap vs. stable memory
- Canister Lifecycle: upgrade hooks and canister lifecycle
- Stable Structures (Rust): deep dive into
ic-stable-structures - Motoko: Motoko language overview and persistence model