Model Starknet storage safely with a single #[storage] struct in Cairo
Define all persistent state in one #[storage] struct and access it via compiler-generated accessors. This prevents slot collisions, makes upgrades safer, and avoids felt252 overflow bugs in Cairo 1.x Starknet contracts.
23 Jul 2025, 03:54 UTC

Use one #[storage] struct and compiler accessors, never hand-roll slots
\nThe practical problem in Cairo 1.x contracts is slot collisions and silent state corruption on upgrade. The useful takeaway is to declare all persistent state in a single #[storage] struct and access it only through the compiler-generated accessors. The compiler derives deterministic storage addresses from variable names and map keys for you. Hand-rolling addresses or reordering fields breaks that guarantee.
\nHow the storage model works
\nStarknet storage is a flat key-value map addressed by field elements. Cairo 1.x does not let you pick raw addresses. Instead you define a #[storage] struct inside a #[starknet::contract] module. Each field maps to a base slot derived from a hash of its name. For a Map<K,V>, the actual slot is derived from the base slot plus a hash of the key. Using the generated accessors keeps you compatible with the layout rules and prevents collisions.
\nValue types matter. felt252 arithmetic wraps modulo the Stark field prime. For token balances, counters or any arithmetic that must not wrap, prefer u256, u128 or u64 with explicit checked operations. Using felt252 for balances is a common source of silent overflow.
\nWorked configuration
\nThe following is a minimal, version-agnostic sketch of the pattern. Verify the exact accessor names read/write vs entry against your pinned Scarb/Cairo toolchain, as the storage API has evolved across Cairo 1.x releases.
\n#[starknet::contract]
mod token {
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess, Map};
use starknet::{ContractAddress, storage::StorageAccess};
#[storage]
struct Storage {
owner: ContractAddress,
balances: Map,
}
#[constructor]
fn constructor(ref self: Storage, owner: ContractAddress) {
self.owner.write(owner);
}
#[external(v0)]
fn balance_of(self: @Storage, account: ContractAddress) -> u256 {
self.balances.read(account)
}
#[external(v0)]
fn transfer(ref self: Storage, recipient: ContractAddress, amount: u256) {
let sender = starknet::get_caller_address();
let current = self.balances.read(sender);
// use checked arithmetic in real code
self.balances.write(sender, current - amount);
let rec = self.balances.read(recipient);
self.balances.write(recipient, rec + amount);
}
}
\nInside the contract, self.balances.read(account) and self.balances.write(account, value) are generated by the compiler. The compiler computes the underlying storage slot from the struct field name and the map key. You do not compute hashes yourself.
\nUpgradability and layout limits
\nWhen you replace a class hash, the existing storage slots are preserved. The compiler will not stop you from changing the layout. The safe convention is append-only: add new fields at the end, never reorder, remove or retype existing fields. Changing the order or type of an existing field silently remaps data to different slots and corrupts state.
\nMap entries cannot be enumerated on-chain. There is no way to iterate over all keys. If you need iteration, maintain a separate index structure such as an append-only list of addresses alongside the map.
\nEvery storage write costs gas on Starknet. Pack related values where possible, batch updates into a single write when logic allows, and avoid storing data that can be derived off-chain or emitted via events.
\nCommon mistakes to avoid
\n- \n
- Hand-rolling storage addresses with Poseidon hashes. This bypasses the compiler layout and risks collisions with future fields. \n
- Using felt252 for balances or counters. Arithmetic wraps silently. Use u256/u128 with explicit checks. \n
- Reordering or retyping storage fields in an upgrade. This is the most common source of silent corruption. \n
- Assuming Map is iterable. On-chain enumeration is not supported. \n
How to check the result
\nCompile a minimal contract with a plain field and a Map, deploy to a local devnet such as starknet-devnet or Katana, and perform a round-trip read/write for both the plain field and a map entry. Then write a test that simulates an upgrade with a reordered storage layout and observe that reads return incorrect values. This demonstrates why append-only layout matters. Check the Scarb/Cairo release notes for your pinned toolchain to confirm the exact storage accessor API and Map behavior for your version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.