Opening: contracts are not “stateless functions”, storage is the soul
Let’s start with the honest version.
Most people writing their first smart contract think like this:
“It’s just a bit of business logic. I’ll add a couple of
#[ink(message)]functions, change some values, maybe transfer tokens. Done.”
Reality looks more like:
- You happily keep adding fields to
#[ink(storage)]as features grow; - A few weeks later, nobody remembers who owns what, which NFTs belong to whom, or which orders are still pending—everything is packed into one giant struct;
- When the product team asks for “multi‑role permissions, whitelists, frozen funds”, the current storage layout simply can’t handle it. Either you refactor half the contract or keep bolting on more flags and lists until future‑you hates present‑you.
A more helpful mental model is: storage is your house‑wide storage system.
#[ink(storage)]is the whole apartment;- Basic fields are built‑in cabinets in rooms;
Mappingis a cabinet with many labeled drawers: eachkeyis a label;- “Lazy configs” and “big objects” are the suitcases you only pull out occasionally.
In this article, we’ll walk through the most common storage patterns in ink!, in a chatty, practical style. The goals:
- You know what your state should look like instead of shoving fields anywhere.
- You walk away with copy‑pasteable templates for balances, permission matrices, and lazy configs.
- You understand which operations are expensive or impossible on‑chain, and avoid them upfront.
This guide is inspired by WeDev’s Medium article “Rust Smart Contract #6 — Smart Contract Storage Patterns in ink!”. Here we retell the core ideas in a more hands‑on style and adapt them to common real‑world patterns.
Core ideas: a few facts you can’t argue with
Before touching code, let’s nail down a few facts:
Any persistent state must live under
#[ink(storage)]- Local variables you
letinside a function disappear when the call ends. - If you want to see a value on the next call, it must be written into storage first.
- Local variables you
Reading and writing storage costs gas, and writes are much more expensive
- The bigger and more frequently changed your state is, the more the chain pays.
- Splitting big objects and writing only what you actually changed is the best “gas discount” you’ll get.
Mapping<K, V>is a key‑value store, not a regularHashMap- Pro: you can hold a huge number of records and look up by key efficiently.
- Con: there is no “give me all keys” on‑chain—keys are logical, the underlying storage is hashed.
- Meaning: if you want “iterate all users”, you must maintain an index like
StorageVec<AccountId>orMapping<u32, AccountId>.
Use “composite keys” to model relationships instead of nesting structs everywhere
- Balances:
Mapping<AccountId, Balance>; - Allowances:
Mapping<(Owner, Spender), Balance>; - Avoid things like
struct User { allowances: Vec<Allowance> }—deleting or updating a single relation becomes painful.
- Balances:
Once a storage layout hits mainnet, treat it as almost frozen
- ink! supports upgrades, but changing the layout is closer to “moving apartments” than “tweaking a field”.
- It’s cheaper to think about entity boundaries and relationships early than to migrate half your state later.
We’ll now go through the patterns from simple to more advanced and keep everything copy‑paste friendly.
Walkthrough: from “one field” to “multi‑dimensional mappings”
Pattern 1: single‑value storage — configs and global flags
Let’s start with the most boring but useful pattern: global config and flags.
Think of it as your main breaker + a few global settings.
#![cfg_attr(not(feature = "std"), no_std)]
#[ink::contract]
mod config_storage {
#[ink(storage)]
pub struct ConfigStorage {
owner: AccountId,
/// Emergency pause flag
paused: bool,
/// Hard cap on total supply
max_supply: Balance,
}
impl ConfigStorage {
#[ink(constructor)]
pub fn new(max_supply: Balance) -> Self {
Self {
owner: Self::env().caller(),
paused: false,
max_supply,
}
}
#[ink(message)]
pub fn pause(&mut self) {
self.only_owner();
self.paused = true;
}
#[ink(message)]
pub fn unpause(&mut self) {
self.only_owner();
self.paused = false;
}
#[ink(message)]
pub fn is_paused(&self) -> bool {
self.paused
}
fn only_owner(&self) {
assert_eq!(self.env().caller(), self.owner, "not owner");
}
}
}
Typical use cases:
- Contract owner / admin account;
- Global switches (pause, maintenance mode);
- Single thresholds (max supply, fee rate).
What you get immediately
You now have a mental slot called “single‑value storage”. Simple config doesn’t need mappings. Just put it straight on the storage struct and wrap it with small #[ink(message)] getters/setters.
Pattern 2: one‑dimensional Mapping — balances and simple tables
Next up is the classic “who owns how much” problem.
We’ll reuse the style from the events & error‑handling article and build a minimal balances table:
#![cfg_attr(not(feature = "std"), no_std)]
#[ink::contract]
mod balances {
use ink::storage::Mapping;
#[ink(storage)]
pub struct Balances {
total_supply: Balance,
balances: Mapping<AccountId, Balance>,
}
impl Balances {
#[ink(constructor)]
pub fn new() -> Self {
Self {
total_supply: 0,
balances: Mapping::default(),
}
}
/// Mint tokens to a specific account
#[ink(message)]
pub fn mint(&mut self, to: AccountId, amount: Balance) {
let old = self.balances.get(&to).unwrap_or(0);
self.balances.insert(to, &(old.saturating_add(amount)));
self.total_supply = self.total_supply.saturating_add(amount);
}
/// Read balance of an account
#[ink(message)]
pub fn balance_of(&self, who: AccountId) -> Balance {
self.balances.get(&who).unwrap_or(0)
}
}
}
Important details:
Mapping::get(&key)returns anOption<V>, so you must handle theNonecase, e.g.unwrap_or(0).- For counters and balances, prefer
saturating_addto avoid accidental overflows. - You don’t need to hash or encode the key manually—ink! will do that when storing.
What you get immediately
Whenever you need “simple table keyed by account”, reach for Mapping<AccountId, Balance> rather than inventing your own list. Most ERC‑20/PSP22‑like balances can be implemented as this pattern plus permission checks and events.
Pattern 3: two‑dimensional Mapping — permissions and allowances
Once relationships become “who can do what on behalf of whom”, one dimension isn’t enough.
The classic example is an ERC‑20/PSP22 allowance:
“A approves B to spend up to 100 tokens on their behalf.”
This is naturally a (owner, spender) -> amount mapping:
#![cfg_attr(not(feature = "std"), no_std)]
#[ink::contract]
mod allowance_storage {
use ink::storage::Mapping;
#[ink(storage)]
pub struct TokenWithAllowance {
owner: AccountId,
balances: Mapping<AccountId, Balance>,
/// (owner, spender) -> allowance
allowances: Mapping<(AccountId, AccountId), Balance>,
}
impl TokenWithAllowance {
#[ink(constructor)]
pub fn new() -> Self {
Self {
owner: Self::env().caller(),
balances: Mapping::default(),
allowances: Mapping::default(),
}
}
#[ink(message)]
pub fn approve(&mut self, spender: AccountId, amount: Balance) {
let caller = self.env().caller();
self.allowances.insert((caller, spender), &amount);
}
#[ink(message)]
pub fn allowance(&self, owner: AccountId, spender: AccountId) -> Balance {
self.allowances.get(&(owner, spender)).unwrap_or(0)
}
}
}
Key takeaways:
- Don’t stick a
Vec<Allowance>into aUserstruct and scan it on every call. - Use composite keys like
(owner, spender)—they behave like a composite primary key in a SQL table. - Need a third dimension, like
(token_id, owner, operator)? You can pack that into a tuple as well.
What you get immediately
Any “who‑can‑do‑what‑for‑whom” relationship can live in Mapping<(A, B), V>. This keeps reads O(1) and avoids custom search loops.
Pattern 4: lazy & big objects — configs that shouldn’t be loaded every call
Some pieces of data are big and rarely used:
- A large “global config” struct;
- Full character stats in a game;
- A long‑term whitelist snapshot.
If you embed them directly in #[ink(storage)], they get pulled into scope every time you touch storage—wasteful and expensive.
There are two common strategies:
Strategy A: split big objects into multiple mappings
Break the big struct into separate fields and store each in its own mapping:
use ink::storage::Mapping;
#[ink(storage)]
pub struct PlayerStorage {
/// Player registry (helps with indexing/iteration)
players: Mapping<AccountId, bool>,
/// Experience points
xp: Mapping<AccountId, u64>,
/// Level
level: Mapping<AccountId, u8>,
}
Pros:
- You only read what you need: XP without touching level, or vice versa.
- Writes are also more granular.
Strategy B: Option<T> for lazy one‑time initialization
Another straightforward approach is to use Option<T> as a “lazy init” container:
#[derive(scale::Encode, scale::Decode, Default)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, Debug))]
pub struct GlobalConfig {
fee_rate_bps: u16,
min_deposit: Balance,
}
#[ink(storage)]
pub struct ConfigLazy {
config: Option<GlobalConfig>,
}
impl ConfigLazy {
#[ink(constructor)]
pub fn new() -> Self {
Self { config: None }
}
#[ink(message)]
pub fn init_config(&mut self, fee_rate_bps: u16, min_deposit: Balance) {
// Only allow initialization once
assert!(self.config.is_none(), "config already initialized");
self.config = Some(GlobalConfig {
fee_rate_bps,
min_deposit,
});
}
#[ink(message)]
pub fn fee_rate(&self) -> u16 {
self.config.as_ref().map(|c| c.fee_rate_bps).unwrap_or(0)
}
}
The idea:
- Start with no config stored, saving space;
- Initialize it when really needed;
- Define sensible defaults for the “not initialized yet” case.
If you’re comfortable with ink!’s built‑in Lazy<T>, you can also use that for big objects: it loads the data only when accessed.
What you get immediately
When you see a big, rarely‑used struct, think in two switches:
- “Can I split this into several mappings?”
- “If not, can I at least wrap it in
Option/Lazyand only initialize/load it once?”
Pattern 5: enum + mapping — stop sprinkling boolean landmines
One last pattern that dramatically improves readability.
You might see storage like:
#[ink(storage)]
pub struct Project {
is_created: bool,
is_active: bool,
is_paused: bool,
is_finished: bool,
}
You can already feel the pain: what if is_active and is_finished are both true?
A much cleaner approach is enum + mapping:
#[derive(scale::Encode, scale::Decode, PartialEq, Eq, Copy, Clone)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, Debug))]
pub enum ProjectStatus {
Draft,
Active,
Paused,
Finished,
}
#[ink(storage)]
pub struct Projects {
statuses: Mapping<u64, ProjectStatus>, // project_id -> status
}
impl Projects {
#[ink(message)]
pub fn set_status(&mut self, id: u64, status: ProjectStatus) {
self.statuses.insert(id, &status);
}
#[ink(message)]
pub fn status_of(&self, id: u64) -> ProjectStatus {
self.statuses.get(&id).unwrap_or(ProjectStatus::Draft)
}
}
Benefits:
- Exactly one state at a time—no contradictory boolean combos.
- You can read the status in one line and know what phase the project is in.
- Adding a new status later is just adding another enum variant.
What you get immediately
Whenever you see three or more is_xxx flags on the same entity, ask yourself: “Can this be an enum?” If yes, store that enum in a mapping instead.
Pitfalls and fixes: what’s expensive or impossible on‑chain
The biggest traps around storage come from reusing off‑chain habits. A few things to keep in mind:
Iterating over
Mappingon‑chain- Problem:
Mappingdoes not expose “all keys”, because keys are hashed and not stored as a simple list. - Fix:
- Maintain your own index:
StorageVec<Key>orMapping<u32, Key>; - Or let an off‑chain indexer/Backend handle full scans.
- Maintain your own index:
- Problem:
Storing large vectors/lists and mutating them heavily
- Problem: inserting/removing in
Vec<T>can cause lots of data shuffling, which is expensive on‑chain. - Fix:
- Prefer
Mapping<Id, Item>for big collections; - If you must keep order, store IDs in
StorageVecand map IDs → data inMapping.
- Prefer
- Problem: inserting/removing in
Changing storage structs and expecting “magic upgrade compatibility”
- Problem: upgrades do not automatically migrate old layouts; changing field order/types can make old data unreadable.
- Fix:
- Design layouts with long‑term evolution in mind: version fields, enums with reserved variants.
- For major changes, design a proper migration process instead of editing struct definitions in place.
Storing addresses/IDs as
Stringeverywhere- Problem:
Stringis larger and slower than necessary on‑chain. - Fix:
- Use
AccountIdfor accounts; - Use
u64/u128/Hashfor resource IDs.
- Use
- Problem:
Magic numbers scattered across storage and logic
- Problem: random
10000,365,42literals make future changes painful. - Fix:
- Centralize them in a config struct (see Pattern 1/4) and read them through one place.
- Problem: random
Wrap‑up and next steps: treat storage like database schema design
Let’s compress the story into a few lines:
- Mindset: treat
#[ink(storage)]as your on‑chain database schema, not a random dump of fields. - Toolbox: get comfortable with these core patterns:
- Single‑value fields (config, flags);
- One‑dimensional
Mapping<K, V>(balances); - Composite‑key mappings (permission/allowance matrices);
- Split mappings +
Option/Lazyfor big objects; - Enum + mapping for state machines.
- Workflow: before writing business logic, sketch entities and their relationships, then map them to these patterns.
Concrete next steps:
- Pick one of your existing simple contracts and redesign its storage using these patterns—can you simplify it?
- Take your balances + allowance contract and add a “project status” module backed by enum + mapping.
- Write a one‑pager for your team summarizing these storage patterns as a checklist for new ink! contracts.
Support and further reading
If this article helped you make sense of ink! storage design, consider:
- Sharing it with teammates who are about to ship their first Rust smart contract;
- Walking through it alongside our events and testing articles to get a full “state + observability + validation” picture;
- Connecting it with the access control chapter so “who can change state” matches “how state is structured”;
- Following “梦兽编程 (DreamBeast Coding)” for more Rust / blockchain / backend content broken down into weekend‑sized projects.
Related Rust smart contract guides:
- Part 1 — Counter walkthrough: /en/tutorials/smart-contracts/rust-smart-contract-ink-counter/
- Part 3 — Events & error handling: /en/tutorials/smart-contracts/ink-events-error-handling/
- Part 4 — Testing ink! contracts: /en/tutorials/smart-contracts/ink-testing-smart-contracts/
- Part 5 — Access control patterns: /en/tutorials/smart-contracts/rust-smart-contract-access-control/
