Skip to main content
IntermediateEst9 minsdk-rs^0.64.1Reference

Storage mappers: which to pick, when

A contract exercising six storage mappers side by side, plus a decision table built from the real multiversx-sc crate source doc comments. The contract was manually built and tested at authoring time; this reference page is not compiled continuously by the cookbook CI. Two easy-to-get-wrong entries (a mapper's contains() complexity and its storage cost) are called out below in "Two clarifications from the crate source".

Prerequisites​

The contract​

storage-mappers/src/storage_mappers.rs
#![no_std]

use multiversx_sc::imports::*;

pub mod storage_mappers_proxy;

/// One endpoint pair per mapper type this recipe covers. This code was manually
/// verified at authoring time, not continuously in CI. Method names and
/// complexity claims below are taken directly from the real `multiversx-sc`
/// crate source doc comments
/// (`~/.cargo/registry/.../multiversx-sc-0.64.2/src/storage/mappers/*.rs`).
#[multiversx_sc::contract]
pub trait StorageMappers {
#[init]
fn init(&self) {}

#[upgrade]
fn upgrade(&self) {}

// ---- SingleValueMapper: one value, no parameters. Simplest mapper,
// baseline for storage cost (1 entry). ----
#[endpoint(setCounter)]
fn set_counter(&self, value: BigUint) {
self.counter().set(value);
}

#[view(getCounter)]
#[storage_mapper("counter")]
fn counter(&self) -> SingleValueMapper<BigUint>;

// ---- VecMapper: ordered, 1-indexed, allows duplicates, random
// access by index. ----
#[endpoint(pushItem)]
fn push_item(&self, item: ManagedBuffer) -> usize {
self.items().push(&item)
}

#[view(getItem)]
fn get_item(&self, index: usize) -> ManagedBuffer {
self.items().get(index)
}

#[view(itemCount)]
fn item_count(&self) -> usize {
self.items().len()
}

#[storage_mapper("items")]
fn items(&self) -> VecMapper<ManagedBuffer>;

// ---- SetMapper: ordered (insertion order) set. The crate source confirms
// O(1) contains via an internal value->node_id lookup. ----
#[endpoint(addToOrderedSet)]
fn add_to_ordered_set(&self, value: u64) -> bool {
self.ordered_set().insert(value)
}

#[view(orderedSetContains)]
fn ordered_set_contains(&self, value: u64) -> bool {
self.ordered_set().contains(&value)
}

#[view(orderedSetLen)]
fn ordered_set_len(&self) -> usize {
self.ordered_set().len()
}

#[storage_mapper("ordered_set")]
fn ordered_set(&self) -> SetMapper<u64>;

// ---- UnorderedSetMapper: no ordering guarantee, O(1) contains via
// VecMapper + a reverse index lookup (2N+1 entries total). ----
#[endpoint(addToUnorderedSet)]
fn add_to_unordered_set(&self, value: u64) -> bool {
self.unordered_set().insert(value)
}

#[view(unorderedSetContains)]
fn unordered_set_contains(&self, value: u64) -> bool {
self.unordered_set().contains(&value)
}

#[view(unorderedSetLen)]
fn unordered_set_len(&self) -> usize {
self.unordered_set().len()
}

#[storage_mapper("unordered_set")]
fn unordered_set(&self) -> UnorderedSetMapper<u64>;

// ---- WhitelistMapper: membership-only, no iteration, most
// space-efficient of the set-shaped mappers. ----
#[endpoint(addToWhitelist)]
fn add_to_whitelist(&self, address: ManagedAddress) {
self.whitelist().add(&address);
}

#[view(isWhitelisted)]
fn is_whitelisted(&self, address: ManagedAddress) -> bool {
self.whitelist().contains(&address)
}

#[storage_mapper("whitelist")]
fn whitelist(&self) -> WhitelistMapper<ManagedAddress>;

// ---- MapMapper: key-value with iteration, HashMap-like API. Uses a
// SetMapper internally for key tracking plus separate value
// storage — the crate source confirms the 4N+1 entries this costs. ----
#[endpoint(setBalance)]
fn set_balance(&self, address: ManagedAddress, amount: BigUint) {
self.balances().insert(address, amount);
}

#[view(getBalance)]
fn get_balance(&self, address: ManagedAddress) -> BigUint {
self.balances().get(&address).unwrap_or_default()
}

#[view(hasBalanceEntry)]
fn has_balance_entry(&self, address: ManagedAddress) -> bool {
self.balances().contains_key(&address)
}

#[storage_mapper("balances")]
fn balances(&self) -> MapMapper<ManagedAddress, BigUint>;
}

The decision table​

MapperOrderingMembership checkStorage entries for N itemsIterable?Pick it when
SingleValueMapper<T>n/a (one value)n/a1n/aYou need exactly one value: a counter, a config flag, a total.
VecMapper<T>Insertion orderLinear scan onlyN + 1Yes, 1 to len()Ordered, indexable, append-friendly storage without fast membership checks. Indexes start at 1, not 0.
SetMapper<T>Insertion order (doubly-linked internally)O(1), confirmed from the crate source~3N + 1Yes, in insertion order, plus next()/previous()You need both ordered iteration AND fast membership checks.
UnorderedSetMapper<T>NoneO(1)2N + 1 (the reverse-lookup keys are easy to undercount as N+1)Yes, arbitrary orderFast membership checks, order does not matter: deduping, a processed-IDs set.
WhitelistMapper<T>n/aO(1), most storage-efficientNNo, cannot enumerate at all"Is X allowed?" only, never "list everyone allowed."
MapMapper<K,V>Insertion order of keysO(1) via contains_key()~4N + 1Yes: .iter(), .keys(), .values()A real key-value store with iteration: balances, per-user settings.
LinkedListMapper<T>Insertion order, efficient front/back opsNot built in~2N + 1YesEfficient push/pop from both ends. Not exercised with working code here, see "What this recipe did not test."

The storage-cost column is expressed in unique storage keys, not bytes; it captures the relative ordering between choices for the same logical data, not an absolute gas number.

Two clarifications from the crate source​

Read directly from multiversx-sc-0.64.2's source doc comments:

SetMapper's contains() is O(1), not O(n)

It is easy to assume SetMapper.contains() is O(n) because the mapper keeps insertion order, but the crate source's doc comment states plainly: "Contains: contains(value) - Checks membership. O(1) with one storage read.", listing "O(1) insert, remove, and contains" as a Pro. SetMapper maintains a separate value→node-ID lookup specifically to make contains() O(1); that is why its storage layout is more complex than a plain ordered list.

UnorderedSetMapper costs 2N+1 entries, not N+1

It is easy to undercount this as N+1. The real storage layout has value storage (.len + .item{index}, the N+1) AND a separate .index{encoded_value} reverse-lookup key per element, which is what actually delivers O(1) contains(). You cannot get O(1) membership testing from N+1 keys with no reverse index.

Everything else this recipe independently checked against the crate source held up, including MapMapper's "4N+1 entries (expensive!)", confirmed by reading how it is built on top of SetMapper internally plus its own value storage.

Tests​

storage-mappers/tests/storage_mappers_blackbox_test.rs
// tests/storage_mappers_blackbox_test.rs — one test per mapper this recipe
// covers, each proving the specific behavioral claim its decision-table entry
// makes (not just "it compiles").

use multiversx_sc_scenario::imports::*;

use storage_mappers::storage_mappers_proxy;

const OWNER: TestAddress = TestAddress::new("owner");
const CONTRACT: TestSCAddress = TestSCAddress::new("storage-mappers-contract");
const CODE_PATH: MxscPath = MxscPath::new("output/storage-mappers.mxsc.json");

fn world() -> ScenarioWorld {
let mut blockchain = ScenarioWorld::new();
blockchain.register_contract(CODE_PATH, storage_mappers::ContractBuilder);
blockchain
}

fn deploy(world: &mut ScenarioWorld) {
world.account(OWNER).nonce(1);
world
.tx()
.from(OWNER)
.typed(storage_mappers_proxy::StorageMappersProxy)
.init()
.code(CODE_PATH)
.new_address(CONTRACT)
.run();
}

#[test]
fn single_value_mapper_set_and_get() {
let mut world = world();
deploy(&mut world);

world
.tx()
.from(OWNER)
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.set_counter(BigUint::<StaticApi>::from(42u64))
.run();

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.counter()
.returns(ExpectValue(BigUint::<StaticApi>::from(42u64)))
.run();
}

#[test]
fn vec_mapper_is_one_indexed() {
let mut world = world();
deploy(&mut world);

// Push three items; VecMapper's own doc comment says indexes start
// at 1 — confirm index 1 is the FIRST push, not the second.
world
.tx()
.from(OWNER)
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.push_item(ManagedBuffer::<StaticApi>::from(b"first"))
.run();
world
.tx()
.from(OWNER)
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.push_item(ManagedBuffer::<StaticApi>::from(b"second"))
.run();

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.item_count()
.returns(ExpectValue(2usize))
.run();

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.get_item(1usize)
.returns(ExpectValue(ManagedBuffer::<StaticApi>::from(b"first")))
.run();

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.get_item(2usize)
.returns(ExpectValue(ManagedBuffer::<StaticApi>::from(b"second")))
.run();
}

#[test]
fn set_mapper_contains_and_ordering() {
let mut world = world();
deploy(&mut world);

for value in [30u64, 10u64, 20u64] {
world
.tx()
.from(OWNER)
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.add_to_ordered_set(value)
.run();
}

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.ordered_set_contains(10u64)
.returns(ExpectValue(true))
.run();

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.ordered_set_contains(99u64)
.returns(ExpectValue(false))
.run();

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.ordered_set_len()
.returns(ExpectValue(3usize))
.run();
}

#[test]
fn unordered_set_mapper_contains_after_insert_and_absent_value() {
let mut world = world();
deploy(&mut world);

world
.tx()
.from(OWNER)
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.add_to_unordered_set(7u64)
.run();

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.unordered_set_contains(7u64)
.returns(ExpectValue(true))
.run();

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.unordered_set_contains(8u64)
.returns(ExpectValue(false))
.run();

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.unordered_set_len()
.returns(ExpectValue(1usize))
.run();
}

#[test]
fn whitelist_mapper_membership_only() {
let mut world = world();
deploy(&mut world);
let allowed: TestAddress = TestAddress::new("allowed-user");
let stranger: TestAddress = TestAddress::new("stranger");
world.account(allowed).nonce(1);
world.account(stranger).nonce(1);

world
.tx()
.from(OWNER)
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.add_to_whitelist(allowed.to_address())
.run();

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.is_whitelisted(allowed.to_address())
.returns(ExpectValue(true))
.run();

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.is_whitelisted(stranger.to_address())
.returns(ExpectValue(false))
.run();
}

#[test]
fn map_mapper_insert_get_and_contains_key() {
let mut world = world();
deploy(&mut world);
let holder: TestAddress = TestAddress::new("balance-holder");
let nobody: TestAddress = TestAddress::new("no-balance");
world.account(holder).nonce(1);
world.account(nobody).nonce(1);

world
.tx()
.from(OWNER)
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.set_balance(holder.to_address(), BigUint::<StaticApi>::from(500u64))
.run();

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.get_balance(holder.to_address())
.returns(ExpectValue(BigUint::<StaticApi>::from(500u64)))
.run();

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.has_balance_entry(holder.to_address())
.returns(ExpectValue(true))
.run();

// A key that was never inserted: contains_key is false, and the
// convenience getter's unwrap_or_default() reads as zero rather than
// erroring — a real design choice worth testing explicitly, since it
// means "balance of zero" and "never had an entry" are
// indistinguishable through get_balance alone.
world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.has_balance_entry(nobody.to_address())
.returns(ExpectValue(false))
.run();

world
.query()
.to(CONTRACT)
.typed(storage_mappers_proxy::StorageMappersProxy)
.get_balance(nobody.to_address())
.returns(ExpectValue(BigUint::<StaticApi>::from(0u64)))
.run();
}
cargo test

8/8 passing: the 6 tests above (one per mapper, each proving the specific claim in the table: VecMapper's first push lands at index 1, SetMapper / UnorderedSetMapper.contains() returns correctly for present and absent values, WhitelistMapper checked via contains() only, MapMapper's zero-default trap), plus the 2 scaffold-provided scenario tests.

Every method name used (.insert(), .contains(), .contains_key(), .add(), .push()) was copied from the real crate source's own doc-comment examples, worth calling out since, for instance, SetMapper / UnorderedSetMapper both use .contains() while MapMapper uses .contains_key() instead, an easy name to get wrong by assuming symmetry.

Pitfalls​

Pitfall 1: VecMapper is 1-indexed

Index 0 is invalid and panics. In the authoring-time manual test, the recipe pushed two items and read back index 1 as the first one pushed.

Pitfall 2: MapMapper.get() returning a default value looks identical to a real stored zero

If "never set" and "set to zero" need to be distinguishable, check contains_key() explicitly rather than trusting a default-valued read.

Pitfall 3: SetMapper and UnorderedSetMapper both offer O(1) contains()

The deciding factor between them is ordering and storage cost, not lookup speed. Pick UnorderedSetMapper unless you specifically need insertion-order iteration or next()/previous() navigation.

Pitfall 4: WhitelistMapper cannot list its members at all

Not even inefficiently. If you might ever need to enumerate, use SetMapper or UnorderedSetMapper from the start; there is no way to add enumeration later without migrating storage.

Pitfall 5: method names differ across the mapper family

SetMapper / UnorderedSetMapper.contains() vs MapMapper.contains_key(), check the exact mapper's own method names rather than assuming consistency.

What this recipe did not test​

LinkedListMapper, QueueMapper, UserMapper, UniqueIdMapper, and BiDiMapper are real, exported mapper types, but this recipe does not include code exercising them: six manually verified mapper examples were already substantial scope. FungibleTokenMapper / NonFungibleTokenMapper / TokenAttributesMapper need real ESDT system contract interaction to demonstrate meaningfully, which belongs in a token-issuance-from-a-contract recipe, not a storage-mapper comparison.

See also​