For AI agents: Documentation index at /llms.txt

Skip to content

Stable Structures

Stable structures are data structures that read and write directly to stable memory, bypassing the heap entirely. Unlike heap data, stable memory survives canister upgrades. No pre_upgrade/post_upgrade serialization hooks required.

The ic-stable-structures crate provides the building blocks. This page covers how to use them in Rust canisters.

On ICP, heap memory (Wasm linear memory) is preserved across update calls within a session but is wiped on every canister upgrade. Any data you store in a plain HashMap or Vec on the heap will be lost the next time you icp deploy.

The two approaches to persistence across upgrades are:

ApproachWhen to use
Stable structuresRecommended for all new canisters. Data lives in stable memory directly: no serialization step, no instruction-limit risk.
Pre/post upgrade hooksSimple to add to existing code, but does not scale. Serializing large datasets in pre_upgrade can hit the instruction limit and brick the canister.

Stable structures eliminate the upgrade risk entirely. The MemoryManager partitions stable memory (which can grow to hundreds of GB) into independent virtual regions, one per data structure.

Add ic-stable-structures to your Cargo.toml:

[package]
name = "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"

ciborium provides CBOR serialization for custom types stored in stable memory. CBOR is compact and fast: preferred over Candid for this use case.

The crate provides several persistent data structures. See the full API reference to look up each type:

TypeUse case
StableBTreeMapKey-value store. Keys must implement Storable + Ord.
StableCellSingle persistent value: counters, configuration, state flags.
StableLogAppend-only log. Efficient for event streams and audit trails.
StableVecOrdered sequence. Efficient indexed access.
StableBTreeSetSet of unique keys. Efficient membership tests and range queries.
StableMinHeapPriority queue: smallest element dequeued first.

Every key and value stored in a stable structure must implement the Storable trait, which defines how the value is serialized to and from bytes in stable memory. Primitive types (u64, u32, bool, f64), String, Vec<u8>, and Principal implement Storable already.

For custom structs, implement Storable 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_at: u64,
}
impl Storable for User {
// Use Unbounded to avoid compatibility issues when adding new fields.
// Bound::Bounded requires a fixed max_size: exceeding it after a
// schema change breaks deserialization of existing data.
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)
}
// `into_bytes` was added in ic-stable-structures 0.7. If you are upgrading
// from 0.6.x, add this method: it is not required in 0.6.
fn into_bytes(self) -> Vec<u8> {
let mut buf = vec![];
ciborium::into_writer(&self, &mut buf).expect("failed to encode User");
buf
}
fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
ciborium::from_reader(bytes.as_ref()).expect("failed to decode User")
}
}

The MemoryManager partitions a single stable memory region into virtual regions. Each data structure is allocated its own MemoryId. Two structures that share a MemoryId corrupt each other’s data: always use distinct IDs.

use ic_stable_structures::{
memory_manager::{MemoryId, MemoryManager, VirtualMemory},
DefaultMemoryImpl,
};
type Memory = VirtualMemory<DefaultMemoryImpl>;
// Declare IDs as constants to prevent accidental reuse.
const USERS_MEM_ID: MemoryId = MemoryId::new(0);
const COUNTER_MEM_ID: MemoryId = MemoryId::new(1);
const LOG_INDEX_MEM_ID: MemoryId = MemoryId::new(2);
const LOG_DATA_MEM_ID: MemoryId = MemoryId::new(3);

StableLog requires two separate memory regions: one for the index and one for the data.

The following snippet shows how to wire a StableBTreeMap and a StableCell into a canister using thread_local!. The User struct and its Storable implementation are defined in the previous section.

use ic_stable_structures::{
memory_manager::{MemoryId, MemoryManager, VirtualMemory},
DefaultMemoryImpl, StableBTreeMap,
};
use ic_cdk::{init, post_upgrade};
use std::cell::RefCell;
type Memory = VirtualMemory<DefaultMemoryImpl>;
const 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))
));
static COUNTER: RefCell<ic_stable_structures::StableCell<u64, Memory>> =
RefCell::new(ic_stable_structures::StableCell::init(
MEMORY_MANAGER.with(|m| m.borrow().get(COUNTER_MEM_ID)),
0u64,
).expect("failed to init COUNTER"));
}
#[init]
fn init() {}
#[post_upgrade]
fn post_upgrade() {
// Stable data auto-restores: re-initialize timers or transient state here.
}

Key patterns:

  • thread_local! { RefCell<StableBTreeMap<...>> } is the standard pattern. The RefCell allows interior mutability inside thread_local!; the stable structure itself lives in stable memory, not the heap.
  • #[post_upgrade] is safe to omit for stable data (the data restores automatically), but defining it explicitly is the right place to re-initialize timers or other transient state after an upgrade.
  • ic_cdk::api::time() returns nanoseconds since the Unix epoch as a u64.

For a fully runnable canister with canister methods and ic_cdk::export_candid!(), see the unit_testable_rust_canister example in dfinity/examples.

When a canister needs more than one stable structure, allocate a unique MemoryId for each. StableLog requires two IDs: one for its index and one for its data:

// Declare all IDs as named constants to prevent accidental reuse.
const USERS_MEM_ID: MemoryId = MemoryId::new(0);
const COUNTER_MEM_ID: MemoryId = MemoryId::new(1);
// StableLog requires two separate memory regions.
const LOG_INDEX_MEM_ID: MemoryId = MemoryId::new(2);
const LOG_DATA_MEM_ID: MemoryId = MemoryId::new(3);
// Initialize StableLog in thread_local! the same way as other structures:
// StableLog::init(index_memory, data_memory).expect("failed to init LOG")

IDs are stable across upgrades. Never renumber them. Adding a new structure always gets the next available integer.

StableVec is an ordered, indexed sequence backed by stable memory. Use it when you need positional access by index rather than key-based lookup:

use ic_stable_structures::{StableVec, memory_manager::{MemoryId, MemoryManager, VirtualMemory}, DefaultMemoryImpl};
use ic_cdk::{query, update};
use std::cell::RefCell;
type Memory = VirtualMemory<DefaultMemoryImpl>;
const VEC_MEM_ID: MemoryId = MemoryId::new(0);
thread_local! {
static MEMORY_MANAGER: RefCell<MemoryManager<DefaultMemoryImpl>> =
RefCell::new(MemoryManager::init(DefaultMemoryImpl::default()));
static ITEMS: RefCell<StableVec<u64, Memory>> =
RefCell::new(StableVec::init(
MEMORY_MANAGER.with(|m| m.borrow().get(VEC_MEM_ID))
).expect("failed to init ITEMS"));
}
#[update]
fn push_item(value: u64) {
ITEMS.with(|v| v.borrow_mut().push(&value).expect("failed to push"));
}
#[query]
fn get_item(index: u64) -> Option<u64> {
ITEMS.with(|v| v.borrow().get(index))
}
ScenarioUse
Data that must survive upgrades (user records, balances, settings)Stable structures
Large datasets that could grow beyond a few MBStable structures: stable memory can grow to hundreds of GB
Temporary computation state within a single callHeap (Vec, HashMap)
Caches that can be rebuilt after an upgradeHeap (Vec, HashMap) reconstructed in #[post_upgrade]
Small configuration that changes rarelyStableCell

The pre/post upgrade hook pattern (serializing heap state in pre_upgrade and deserializing in post_upgrade) becomes dangerous as datasets grow. Both hooks run under a fixed instruction limit. Exceeding the limit traps the upgrade, leaving the canister stuck. Stable structures bypass this risk entirely because no serialization step occurs during upgrade.

To confirm your canister actually persists data:

Terminal window
# Start a local network
icp network start -d
# Deploy the canister
icp deploy backend
# Add some data
icp canister call backend add_user '("Alice")'
# Expected: (0 : nat64)
icp canister call backend add_user '("Bob")'
# Expected: (1 : nat64)
# Record the count before upgrade
icp canister call backend get_user_count '()'
# Expected: (2 : nat64)
# Redeploy (simulates a code update + upgrade)
icp deploy backend
# Count must still be 2: not 0
icp canister call backend get_user_count '()'
# Expected: (2 : nat64)
# Data must still be retrievable
icp canister call backend get_user '(0)'
# Expected: (opt record { id = 0 : nat64; name = "Alice"; created_at = ... })

If the count drops to 0 after redeployment, the data is not in stable memory. Check that the structures are initialized inside thread_local! using the MemoryManager, not as plain heap types.

Reusing a MemoryId for two different structures corrupts both. Declare IDs as named constants to prevent mistakes.

Using thread_local! { RefCell<HashMap<...>> } for persistent state. This is heap memory and is wiped on every upgrade. Use StableBTreeMap instead.

Using Bound::Bounded with a max_size that is too small. If you add a field to a struct later, existing records that fit the old max_size may still encode larger than expected, or the new layout may exceed the bound and break deserialization. Prefer Bound::Unbounded unless you have a specific reason to bound the size.

Omitting #[post_upgrade] when you have timers or transient state. Stable data is safe without a #[post_upgrade] hook. The structures read from stable memory automatically on first access. The real reason to define the hook is to re-initialize timers and other transient heap state that is lost on upgrade. If your canister uses timers, omitting #[post_upgrade] means timers silently stop firing after an upgrade.

Serializing heap data in pre_upgrade as the sole persistence strategy. This does not scale. For canisters with user-facing data, use stable structures from the start.

Stable memory is persistent: once you deploy a canister, existing serialized bytes must remain readable after you add or change fields. Bound::Unbounded is the safe default because it allows the encoded size to grow without constraint, so adding a new field to a CBOR-serialized struct does not break reads of old records.

Adding a field: Use Option<T> for new fields so old records (which have no bytes for the field) deserialize correctly as None. CBOR skips unknown fields on deserialization, so a plain new field also works: but Option<T> makes the intent explicit.

// Before upgrade:
struct User { id: u64, name: String }
// After upgrade: old records deserialize with email = None:
struct User { id: u64, name: String, email: Option<String> }

Changing a key type: Changing the key type of a StableBTreeMap (for example, from u32 to u64) is a breaking change: all existing keys are stored as the old type and the new type will not read them. To migrate, allocate a new MemoryId for a new map, copy data from the old map in #[post_upgrade], then remove the old MemoryId in a subsequent upgrade once migration is complete.

Never change Bound::Bounded max_size for live data. Lowering it truncates existing records. Raising it is safe but may require a separate migration if old records are smaller than the new bound expects.