Cairo 1 Storage: Declarative #[storage] vs Manual StoragePointer — A Decision Guide
A decision guide for Cairo 1 StarkNet contracts: when to use the declarative #[storage] struct versus manual StoragePointer access, with trade-offs, example code, and validation steps.
25 Jul 2025, 06:40 UTC

If you're writing a StarkNet contract in Cairo 1 (cairo-lang 2.x), you have two supported ways to reach contract state: the declarative #[storage] struct, and manual access through StoragePointer and explicit base addresses. Picking the wrong one for your constraints costs you either auditability or layout control. This guide states the decision, compares the options, and shows a concrete implementation plus how to validate it.
The decision and its constraints
The question is not "which API is better" but "which API matches your constraints." Three constraints usually dominate:
- Auditability. Will reviewers (or an audit firm) need to reason about every slot your contract touches?
- Upgradeability and layout compatibility. Must your storage layout match an existing contract, a proxy pattern, or a Cairo 0 migration exactly?
- Developer velocity. How much boilerplate and how many slot-arithmetic mistakes can your team afford?
Comparing the two supported options
| Criterion | Declarative #[storage] struct | Manual StoragePointer access |
|---|---|---|
| Idiom status | Standard Cairo 1 idiom for StarkNet contracts | Supported low-level API, intended for special cases |
| Slot assignment | Compiler maps fields to slots at compile time | You choose base addresses explicitly |
| Auditability | High — fields are typed, named, and visible in one struct | Lower — reviewers must trace pointer arithmetic |
| Layout control | Limited — compiler owns the mapping; complex-type layout can shift between compiler versions | Full — you can match a legacy or cross-contract layout byte-for-byte |
| Error surface | Off-by-one slot errors are largely eliminated | Silent slot collisions are possible if base addresses overlap |
| Gas tuning | Good enough for most contracts | Allows custom packing and fine-grained control |
| Tooling | Integrates with Scarb metadata and generated Sierra/CASM declarations | Metadata reflects raw accesses; less self-describing |
Trade-offs in practice
Choose the declarative API when the contract is new, the layout is yours to define, and you want reviewers to see the entire state surface in one place. Typed fields accessed as self.storage.field.read() are hard to misuse, and the compiler rejects type mismatches that manual code would silently accept.
Choose manual StoragePointer access when you must reproduce an exact layout: migrating state from a Cairo 0 contract, sharing a layout with another contract, or packing multiple small values into one felt to save storage writes. The cost is real: nothing stops two pointers from overlapping, and a collision corrupts state without any error at write time. If you go manual, document every base address and keep the arithmetic in one audited module.
A common hybrid is to use the declarative struct for everything and drop to manual access only inside one well-commented function that handles the legacy slot. That keeps the audit surface small while preserving compatibility.
Concrete implementation: declarative first
A minimal Cairo 1 contract using the declarative API looks like this:
#[starknet::contract]
mod Counter {
#[storage]
struct Storage {
count: u256,
owner: starknet::ContractAddress,
}
#[external(v0)]
impl CounterImpl of super::ICounter<ContractState> {
fn increment(ref self: ContractState) {
let current = self.count.read();
self.count.write(current + 1);
}
fn get_count(self: @ContractState) -> u256 {
self.count.read()
}
}
}Note the access pattern: fields are read and written through the generated accessors, never by address. Exact attribute and interface syntax varies slightly across cairo-lang 2.x minor versions, so confirm against your installed compiler before publishing.
For the manual path, the shape is conceptually:
use starknet::storage_access::{StorageBaseAddress, storage_base_address_from_felt252};
// Read a value from an explicitly chosen base address.
let base = storage_base_address_from_felt252(0x01);
let value = starknet::storage_read_syscall(0, base.into()).unwrap_syscall();Run this inside your contract code; it requires no special permissions beyond being executed as part of a contract call. The risk is entirely logical: pick 0x01 in two places with different meanings and you have a silent collision.
Validating the choice
Whatever you pick, verify three things before shipping:
- Compile and inspect metadata. Run
scarb buildfrom the project root (requires the Scarb toolchain installed; no elevated permissions). Inspect the generated Sierra/CASM artifacts and contract metadata to confirm the storage declarations match your expectations. Do not assume slot numbering for complex types is stable across compiler upgrades — re-check after any toolchain bump. - Roundtrip unit tests. With snforge, write a test that calls a write function, then a read function, and asserts the value persists. For manual pointers, add a test that writes to two distinct base addresses and confirms neither read is affected by the other write — this is your collision detector.
- Compare gas if it motivated the decision. If you chose manual access for packing savings, prove it: estimate the same operation both ways on a local devnet and keep the numbers with the decision record. If the saving is negligible, revert to the declarative API.
Limitations
This guide assumes Cairo 1 with cairo-lang 2.x and StarkNet contracts; Cairo 0 storage syntax is entirely different. Storage layout for complex types is compiler-version-sensitive, so any layout you depend on externally should be pinned and re-verified on upgrades. Treat the manual API as an escape hatch with a written justification, not a default.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.