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;
  • Mapping is a cabinet with many labeled drawers: each key is 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:

  1. You know what your state should look like instead of shoving fields anywhere.
  2. You walk away with copy‑pasteable templates for balances, permission matrices, and lazy configs.
  3. 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:

  1. Any persistent state must live under #[ink(storage)]

    • Local variables you let inside 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.
  2. 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.
  3. Mapping<K, V> is a key‑value store, not a regular HashMap

    • 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> or Mapping<u32, AccountId>.
  4. 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.
  5. 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 an Option<V>, so you must handle the None case, e.g. unwrap_or(0).
  • For counters and balances, prefer saturating_add to 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 a User struct 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/Lazy and 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 Mapping on‑chain

    • Problem: Mapping does not expose “all keys”, because keys are hashed and not stored as a simple list.
    • Fix:
      • Maintain your own index: StorageVec<Key> or Mapping<u32, Key>;
      • Or let an off‑chain indexer/Backend handle full scans.
  • 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 StorageVec and map IDs → data in Mapping.
  • 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 String everywhere

    • Problem: String is larger and slower than necessary on‑chain.
    • Fix:
      • Use AccountId for accounts;
      • Use u64/u128/Hash for resource IDs.
  • Magic numbers scattered across storage and logic

    • Problem: random 10000, 365, 42 literals make future changes painful.
    • Fix:
      • Centralize them in a config struct (see Pattern 1/4) and read them through one place.

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/Lazy for 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/