Skip to main content

Storage strategies in production contracts

How experienced teams lay out contract state on Stellar — from a first counter to protocol-scale data layouts. The strategies below are ordered from simplest to most complex and grounded in current production contracts and official examples. They are not mutually exclusive: each one applies to a piece of state, not to a whole contract, and production contracts routinely compose several — a single lending pool keeps its config in instance storage (Strategy 1), one entry per user position (Strategy 2), and a bounded reserve list (Strategy 6). Use the decision path to pick a strategy per piece of state.

Protocol and limits snapshot

Checked: July 20, 2026. Protocol: Stellar Protocol 27.

Mainnet resource limits are network settings that validators can change, and the strategy list itself can grow with the ecosystem. Verify any limit quoted here — such as the 200 disk-read-entry and 200 write-entry caps per transaction — with stellar network settings --network mainnet or on Stellar Laboratory. The current values are tabulated in the appendix.

Why storage is the hard part

In Stellar smart contracts, state is not a free-form database. It is a set of ledger entries: independent key→value pairs. Keys and values can be any Soroban-serializable type — built-ins such as Symbol, numbers, Address, bytes, vectors, and maps, or #[contracttype] structs and enums. The serialized contract-data ledger key, including its fixed fields and user key, is capped at 250 bytes. The entire serialized contract-data ledger entry is capped at 64 KiB, so the value has less than 64 KiB available after entry overhead. Each transaction declares the entries it may read and write in its footprint. Entries also have a TTL (time-to-live, in ledgers); rent is charged when an entry is created or grows and when its TTL is extended. On expiry, persistent and instance entries are archived, while temporary entries are deleted permanently.

That model has three consequences that drive everything below:

  • You cannot iterate what you cannot name. There is no "give me all keys starting with X". To enumerate, you build the index yourself.
  • Reads and writes are limited per transaction. Under the current Mainnet settings, a transaction can read up to 200 entries from disk and write up to 200 entries.
  • State is not free forever. Every byte kept alive has a recurring cost, and every entry needs a TTL management plan.

The three storage tiers

env.storage() gives you three APIs with the same interface but different lifecycles. Watch what happens to each tier's entry when its TTL runs out:

instance()contract instanceAdmin, Config…TTL expiresARCHIVEDcontract instance entrycode has its own TTLRESTOREDinstance entry+ code, if also archivedpersistent()Balance(alice)→ 1_000_000TTL expiresARCHIVEDone persistent entryindependent TTLRESTOREDthis exact key onlytemporary()Allowance(a,b)→ 500TTL expiresDELETEDpermanentno restore path

In the animation, expired instance and persistent entries end archived — no longer accessible to transactions, but recoverable — while an expired temporary entry gets deleted. The two restore buttons simulate restoration of archived instance/persistent entries. On the real network, there are two ways to restore an archived entry:

  • Automatic (since protocol 23): simulate and submit an ordinary invocation that touches the archived key. The RPC server adds that key — plus the contract code entry, if it is archived too — to the transaction's restore list, and the network restores everything on the list before the contract executes.
  • Manual: submit a RestoreFootprintOp naming the exact ledger keys; the CLI wraps this as stellar contract restore.

Either way, restoration charges rent and resource fees, and the restored entry comes back at the minimum persistent TTL.

Expiry and restoration are only part of the story. Side by side, here is how the three tiers compare across their whole lifecycle — where each one lives, what it costs, and roughly what it is good for:

instance()persistent()temporary()
Lives inthe contract instance's own entryits own entry per keyits own entry per key
On TTL expirythe single instance entry is archived; contract code has its own TTL and may be archived separatelyonly that key's entry is archiveddeleted forever
Automatic restorationsimulate and submit an invocation; RPC adds the archived instance and contract code, if needed, to the restore listsimulate and submit an invocation that accesses the key; RPC adds that exact key to the restore listimpossible
Manual restorationsubmit RestoreFootprintOp for the instance and code ledger keys; stellar contract restore --id C... restores the instancesubmit RestoreFootprintOp for that key; stellar contract restore --id C... --key ... wraps itimpossible
TTL extensionone instance call covers all instance data and also extends contract codeeach key is extended separatelyeach key is extended separately
Rentfull rate on the whole instance entryfull rate per entryhalf rate per entry
Loaded whenevery invocation of this contractonly when in the footprintonly when in the footprint
Right forsmall, global, contract-lifetime configdata you must never losedata with a natural deadline, or safely regenerable

Five subtleties that bite newcomers:

  • Nothing extends a TTL automatically. The host never bumps a TTL on access — on any tier. Every extension is an explicit extend_ttl() call by the contract (or a transaction operation). What looks automatic in well-run contracts is the bump-on-access pattern (see Strategy 5), applied to instance and persistent entries alike.
  • Anyone can extend any entry's TTL. ExtendFootprintTTLOp has no access control, so expiry is never a security boundary: if your logic assumes an authorization lapses when its entry expires, a bad actor can keep that entry alive indefinitely. A deadline your contract must enforce belongs in the entry's value, checked in code — the TTL only manages storage lifecycle (Strategy 4).
  • Instance storage is one entry. Everything in instance() shares the contract instance's single ledger entry: it is all loaded on every invocation, it must fit the 64 KiB cap together, and any two transactions that write it run sequentially (the network parallelizes only non-conflicting transactions). Keep it small and mostly read-only. TTLs are all-at-once too: instance().extend_ttl() extends everything together, and applies the same policy to the separate contract code entry with an independent threshold check.
  • Temporary is not "scratch space". It is durable across transactions until its TTL runs out — but expiry permanently deletes it: no archive, no recovery. Use it only for data whose loss is acceptable or whose validity provably ends at a known ledger. In exchange it rents at half price and never needs restoring.
  • Restoration is driven by the transaction, not by contract code. An archived key that is in the footprint but not in the restore list fails the transaction before the contract runs — contract code never observes, and cannot restore, an archived entry. Instance storage restores as one contract instance entry (contract code separately, if also archived); persistent entries restore per key.

That is the machinery every strategy below manages. The rest of this guide walks through the twelve strategies themselves, from simplest to most involved, each in the same shape:

  • You need — the situation you are in.
  • The catch — why the obvious approach hurts.
  • The pattern — the working code.
  • Trade-offs — what it costs.

The italic line under each heading is a quick-facts summary; the storage tiers it names are where production contracts most commonly apply the pattern, not a requirement — most patterns work on any tier whose lifecycle fits the data.

Strategy 1: Singleton config in instance storage

instance · O(1) · no separate footprint entry

You need. A handful of values every call uses: admin address, token addresses, a pause flag, pool reserves, a counter.

The catch. Nearly every call needs these values. If each lived in its own persistent entry, every call would declare and read several entries and manage several TTLs. Instance values add no separate footprint entries, although their bytes still contribute to resource use.

invocationscontract instance — one entryAdminToken0Token1Reserve0Reserve1loaded with each invocation → no separate config footprint entry

The pattern. Put them in instance() storage under unit-like enum keys, and extend the instance TTL as part of normal operation:

#[contracttype]
pub enum DataKey { Admin, Token0, Token1, Reserve0, Reserve1 }

pub fn __constructor(e: Env, admin: Address) {
e.storage().instance().set(&DataKey::Admin, &admin);
}

// contract paths that maintain instance liveness end with the extend_ttl call:
pub fn deposit(e: Env, from: Address, amount: i128) {
// ...business logic that relies on instance state...
e.storage().instance().extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT);
}

Trade-offs

  • O(1), with no separate footprint entry for each field.
  • Everything in it is loaded on every invocation of the contract and shares the 64 KiB cap. A small signer set is reasonable; a user map is not.
  • Instance writes serialize concurrent transactions. Hot mutable data with many independent writers (per-user balances) does not belong here — AMM reserves are acceptable because every swap conflicts logically anyway.

In the wild

Strategy 2: One entry per entity — keyed by a DataKey enum

persistent · O(1) lookup · independently addressed entries · no enumeration

You need. Per-user (or per-asset, per-order, per-anything) state: balances, positions, configs. Unbounded population.

The catch. A naive Map<Address, i128> under one key hits three walls: the 64 KiB entry cap; the write-bytes budget, because every update rewrites the map; and contention, because every user's transaction writes the same entry.

✗ one shared mapMap<Address,i128>every user, one entry→ 64 KiB cap · rewrites · contention✓ one entry per holderBalance(alice) → 120Balance(bob) → 7_500Balance(carol) → 41…one independently addressed entry per user,transactions touch only the entries they need

The pattern. Give every entity its own ledger entry, using an enum variant with a parameter as the key. This is Soroban's equivalent of Solidity's mapping:

#[contracttype]
pub enum DataKey {
Balance(Address), // one persistent entry per holder
}

pub fn read_balance(e: &Env, addr: Address) -> i128 {
let key = DataKey::Balance(addr);
if let Some(balance) = e.storage().persistent().get::<_, i128>(&key) {
e.storage().persistent().extend_ttl(&key, BALANCE_LIFETIME_THRESHOLD, BALANCE_BUMP_AMOUNT);
balance
} else {
0 // absent entry == zero; never write zeros you don't need
}
}

Note the two idioms: absent-means-default (don't create entries to store zero) and bump-on-access (see Strategy 5).

Trade-offs

  • O(1) lookup and update; different users' entries do not conflict with each other, although other shared entries in those transactions still can.
  • You lose enumeration entirely — there is no way to list all Balance(..) keys on-chain. If you need "all holders", add an index (Strategy 8) or index events off-chain.
  • Aggregate rent grows with the population — unbounded users mean an unbounded total — but per-interaction cost stays flat. Rent falls on the transaction's fee payer when an entry is created, enlarged, restored, or extended, so with bump-on-access each user funds their own entry.

In the wild

Strategy 3: Composite keys — multi-dimensional lookups

persistent · temporary · O(1) · serialized ledger key ≤ 250 B

You need. State indexed by two or more dimensions: allowance of (owner, spender), position of (pool, user), mint quota of (contract, minter, epoch).

The catch. Same as Strategy 2 — nested maps in one entry don't scale — plus a new constraint: the 250-byte serialized ledger-key cap. That size includes the contract ID, durability, and XDR-serialized user key, so the user key has less than 250 bytes available. Two addresses plus a few integers fit easily, but never put unbounded strings or vectors in a key.

owners ↓spenders →Allowance(alice, bob)one independent entry per pairevery (owner, spender) combination = its own ledger entry — no nested maps

The pattern. Put a struct (or tuple variant) inside the key:

#[contracttype]
pub struct AllowanceDataKey { pub from: Address, pub spender: Address }

#[contracttype]
pub enum DataKey { Allowance(AllowanceDataKey) }

A positional tuple variant works just as well:

#[contracttype]
pub enum DataKey { Allowance(Address, Address) } // (from, spender)

The difference is size versus safety: the tuple form is smaller (120 B vs 160 B for this key — meaningful under the 250 B cap), while named fields won't let you accidentally swap from and spender when building the key.

Variation — time-bucketed keys. When one dimension is time, put the bucket in the key so data self-partitions: each epoch writes a new key, and old buckets expire on their own (the lifecycle half is Strategy 4). With no enumeration, a bucket is readable only if you can recompute its key — so bucket by time only when old data is disposable or its keys are derivable. The mint-lock example implements this end-to-end:

Trade-offs

  • Still O(1) per lookup; each combination is an independent entry for conflict analysis, although shared entries in the same transactions may still overlap.
  • Key design is API design: you can only look up by the exact full key. If you also need "all spenders for an owner", that's an enumeration problem (Strategy 8).
  • More dimensions create more entries, increasing aggregate rent and resource use.

In the wild

Strategy 4: Temporary storage for data with a deadline

temporary · half rent · deleted on TTL expiry

You need. State that is only meaningful until some ledger: allowances with expiry, live auctions, oracle prices, per-epoch quotas, pending admin proposals.

The catch. Rent and lifecycle. Park ephemeral data in persistent storage and you pay full-rate rent while it is live; when its TTL expires, it archives rather than being deleted. Removing it explicitly costs a write.

Allowance(from, spender){ amount, expiration_ledger }TTL aligned to the deadline when possibledeleted without a cleanup transactionself-pruning oracle historyt−4t−3t−2t−1tone entry per price round, keyed by timestamp;TTL derived from retention → old rounds can expire independentlytemporary entries are deleted on expiry without a cleanup transaction; rent is half the persistent rate

The pattern. Use temporary() storage and align the TTL with the business deadline — then enforce that deadline in the value:

// SEP-41 token allowance (SEP-41 is Soroban's standard token interface)
// Write side: reject deadlines already in the past, then match the TTL to it.
if amount > 0 && live_until_ledger < e.ledger().sequence() {
panic!("live_until_ledger is less than ledger seq when amount > 0");
}
e.storage().temporary().set(&key, &AllowanceValue { amount, live_until_ledger });
if amount > 0 {
let live_for = live_until_ledger.checked_sub(e.ledger().sequence()).unwrap();
e.storage().temporary().extend_ttl(&key, live_for, live_for);
}

// Read side: anyone can extend the entry's TTL, so a live entry can outlast
// the deadline — a past live_until_ledger means no allowance.
let allowance = match e.storage().temporary().get::<_, AllowanceValue>(&key) {
Some(a) if a.live_until_ledger >= e.ledger().sequence() => a.amount,
_ => 0,
};

The TTL alone is not the whole guarantee, for two reasons: the network enforces a minimum TTL on creation (currently 17,280 ledgers, about a day, for temporary entries), and anyone can extend any entry's TTL with an ExtendFootprintTTLOp. So defensive code also stores the deadline in the value and checks it — as above, live_until_ledger lives inside AllowanceValue. The network guarantees deletion once the TTL runs out; your code enforces validity until then.

Trade-offs

  • Half-price rent; entries are deleted on TTL expiry without a cleanup transaction.
  • Deleted means deleted — never put funds-bearing or must-not-lose state here. If nobody extends the entry and it expires early, your code must treat "absent" identically to "expired".
  • Minimum TTL is about 1 day — shorter deadlines must be enforced by the value check, not the TTL.

In the wild

Strategy 5: TTL management — bump-on-access

persistent · instance · rent tracks usage

You need. Persistent data that must not archive while in use — without paying maximum rent on everything forever.

The catch. Every persistent entry archives when its TTL expires. A later transaction can restore it by including it in the restore list, paying restoration rent and resource fees. Extending a TTL costs rent proportional to entry size and the ledgers added, so bumping everything to the maximum on every touch is wasteful, while never bumping guarantees eventual archival. The current maximum is 3,110,400 ledgers — approximately 180 days at today's ~5-second ledger close time.

TTL of Balance(alice)in this zone, an extend_ttl() call extends the TTLTHRESHOLD0BUMPextend_ttl()above threshold → extend_ttl is a no-opextend_ttl()below threshold → extended to BUMP · rent charged with transaction feesif called on every access → at most one extension per 17,280 ledgersnot to scale — with THRESHOLD = BUMP − 17,280 ledgers, the no-op zone is only the top ~3% of the bar

The pattern. A common policy uses two constants: when code explicitly calls extend_ttl() and fewer than THRESHOLD ledgers remain, extend the entry to BUMP. Contracts often call it after successful reads and writes:

pub(crate) const DAY_IN_LEDGERS: u32 = 17280;
pub(crate) const BALANCE_BUMP_AMOUNT: u32 = 30 * DAY_IN_LEDGERS; // target ~30 days at the current close time
pub(crate) const BALANCE_LIFETIME_THRESHOLD: u32 = BALANCE_BUMP_AMOUNT - DAY_IN_LEDGERS; // when < ~29 days remain at the current close time

pub fn balance(e: &Env, addr: Address) -> i128 {
let key = DataKey::Balance(addr);
if let Some(balance) = e.storage().persistent().get::<_, i128>(&key) {
// reading counts as activity: extend the TTL once below the threshold
e.storage().persistent().extend_ttl(&key, BALANCE_LIFETIME_THRESHOLD, BALANCE_BUMP_AMOUNT);
balance
} else {
0
}
}

extend_ttl(threshold, extend_to) changes nothing when the current TTL is at least threshold; otherwise it extends the TTL to extend_to. With THRESHOLD = BUMP − 17,280, repeated calls can extend an active entry at most once per 17,280 ledgers. Examples from current source:

CodebaseData classThreshold → Bump
stellar/soroban-examples tokeninstance / balances6 → 7 d / 29 → 30 d
soroswap/coreinstance / pair registry / LP balances29 → 30 / 59 → 60 / 119 → 120 d
blend-capital/blend-contracts-v2instance / shared protocol / user data30 → 31 / 45 → 46 / 100 → 120 d

Blend uses longer TTL targets for user data than for shared entries: user positions target 120 days' worth of ledgers, while shared reserve data targets 46. These day labels remain approximations based on 17,280 ledgers per day.

The same threshold → bump call exists on temporary storage and suits regenerable caches that should stay live while hot — but a temporary entry that misses its bump is deleted permanently rather than archived, so never rely on it for data you cannot rebuild.

Trade-offs

  • Bump-on-access ties extensions to activity. The fee-paying account pays the extension rent; idle persistent data eventually archives.
  • It cannot keep alive what nobody touches. For data that must stay live through total inactivity, an off-chain keeper can submit ExtendFootprintTTLOp; otherwise, a future transaction must include an archived entry in its restore list.
  • An extend_ttl() call is an explicit resource operation even when it is placed in a getter; rent is charged when it actually extends the TTL. Simulate the final transaction so its resource declaration and fees include the call.

In the wild. Every contract with instance or persistent state deals with TTL management in some capacity. Three examples:

Strategy 6: Bounded collections — a capped Vec or Map in one entry

persistent · 1 read to iterate · hard cap in code

You need. A small list the whole contract shares and iterates: the assets a lending pool supports, a reward-zone registry, a withdrawal queue.

The catch. Iterating N separate entries costs N footprint entries and N reads per transaction. An unbounded Vec in one entry eventually hits the 64 KiB entry limit, and every writer of the list conflicts with every other.

ResList — ONE entry, hard cap in codeUSDCXLMEURCwBTCwETH…30#31rejected: contract's own cap— not a network limitwhole list read in 1 footprint entry · iterate in memory

The pattern. Keep the collection in one entry and enforce a hard cap in code, chosen so the entry stays small and iteration stays cheap:

pub const MAX_RESERVES: u32 = 30; // Blend pool reserve-list cap

pub fn push_res_list(e: &Env, asset: &Address) -> u32 {
let mut res_list = get_res_list(e);
if res_list.len() >= MAX_RESERVES {
panic_with_error!(e, PoolError::BadRequest)
}
let new_index = res_list.len();
res_list.push_back(asset.clone());
e.storage().persistent().set(&Symbol::new(e, RES_LIST_KEY), &res_list);
// + extend_ttl
new_index
}

The list entry stores only identifiers; per-item detail lives in its own entry (Strategies 2 & 3). Reading "the whole configuration" is then 1 + N reads where N ≤ cap.

Trade-offs

  • One read fetches the whole list → O(n) in-memory iteration, no footprint explosion. An in-memory push is O(1), but storing it reserializes and rewrites the whole list entry.
  • The cap is a product decision disguised as an engineering one: Blend pools reject a reserve list beyond 30 entries. Pick caps deliberately from encoded entry size and worst-case iteration cost.
  • Single-entry writes serialize; fine for admin-touched lists, wrong for user-touched ones.
  • Removal from an ordered vector is O(n) because later items shift. For O(1) removal where order does not matter, use swap-and-pop (Strategy 8).

In the wild

Strategy 7: Pack vs. split — group state by access pattern

persistent · size the entry to the transaction · split hot from cold

You need. An entity with several related pieces of state — say a user's collateral, debt, and supplied assets — and a decision: one entry holding all of it, or one entry per piece?

The catch. This is a two-sided trap. Split too much and one operation consumes many footprint entries and reads. Pack too much and each small change re-serializes a large value, approaches the 64 KiB entry cap, and prevents independent writes from running in parallel.

packed — read/written togetherPositions(user)liabilities · collateral · supplyall user positions = 1 readany update rewrites the whole blobsplit — updated independentlyResData(asset) — rates, hotResConfig(asset) — admin, coldhot data updates without rewriting cold configheuristic: pack data usually read and written together

The pattern. Group by how transactions use the data: pieces that are nearly always read and written together may belong in one entry; pieces updated independently or by different actors belong apart. Both layouts are transactionally atomic. Packing reduces footprint; it is not required for atomicity.

Blend's Positions entry is the canonical "pack" example — every borrow, repay, and liquidation must see all of a user's positions to check account health, so they share one entry:

#[contracttype]
pub struct Positions { // ONE persistent entry per user
pub liabilities: Map<u32, i128>, // reserve_index -> dTokens
pub collateral: Map<u32, i128>,
pub supply: Map<u32, i128>,
}

Two details keep Blend's packed Positions entry small:

  1. The maps key by reserve index (u32) rather than asset Address — a smaller map key that doubles as a pointer into the pool's bounded reserve list from Strategy 6.
  2. The entry is explicitly capped: require_under_max rejects any action that would grow a user's count of liability plus collateral positions past the pool's max_positions config — Strategy 6's hard cap applied inside an entry.

Blend's reserves show the split side of the same trade-off: each asset's state is divided into ResConfig (admin-set parameters, cold) and ResData (rates updated on every accrual, hot), so a rate update writes the small hot entry without rewriting the config.

Trade-offs

Packed (1 entry)Split (n entries)
Read whole entity1 readn reads
Update one fieldrewrite whole blob1 small write
Invariant scopeone blobcontract coordinates across entries
Parallelism between partsnonepossible when transactions do not overlap
Size ceiling64 KiB for the whole entry64 KiB per individual entry

In the wild

Strategy 8: Enumeration — build the index yourself

persistent · O(1) maintain · pageable reads

You need. To list things on-chain: all trading pairs a factory created, all tokens an address owns, all members of a role. (Strategy 2 deliberately gave this up.)

The catch. No key iteration exists. An unbounded Vec eventually exceeds the 64 KiB entry cap, and O(n) rewrites eventually exceed transaction budgets. Use an index that is O(1) to maintain and pageable to read.

remove C from {A B C D E} — swap-and-pop, O(1)idx 0idx 1idx 2idx 3idx 4ABCDE1 · read the last item2 · move it into the hole3 · fix its reverse pointer4 · remove the taila constant number of entry updates, regardless of set size — order is not preserved

The pattern — three variants by mutability. (a) Append-only: counter + indexed keys. For sets that only grow, store Total and one entry per index:

// soroswap factory
enum DataKey {
TotalPairs, // instance: u32
PairAddressesNIndexed(u32), // persistent: index -> pair address
PairAddressesByTokens(Pair), // persistent: (tokenA,tokenB) -> pair address
}

Appending = write index n, bump counter. Enumeration = clients loop all_pairs(i) for i in 0..totalpagination is pushed to the caller, which is the point: no single transaction ever needs the whole set. Note Soroswap maintains two indexes over the same data — direct lookup by tokens and positional enumeration.

(b) Mutable set: double mapping + swap-and-pop. When items can also be removed, maintain two mappings — forward (index → item) and reverse (item → index) — so removal is O(1), as animated above:

// OpenZeppelin enumerable NFT, abridged
if to_be_removed_index != last_token_index {
let last_token_id = get_owner_token_id(e, owner, last_token_index);
set(OwnerTokens(owner, to_be_removed_index), last_token_id); // move last into hole
set(OwnerTokensIndex(last_token_id), to_be_removed_index); // fix reverse pointer
}
remove(OwnerTokens(owner, last_token_index)); // pop tail
remove(OwnerTokensIndex(to_be_removed_id)); // drop removed item's reverse entry

(c) Zero on-chain index: events + off-chain indexer. If only off-chain consumers need the listing, emit events and let an indexer (RPC getEvents, Hubble, etc.) build the list. On-chain cost: zero entries. On-chain readability: none.

Trade-offs

Variantaddremovecontainson-chain page readkeeps orderledger entries per item
(a) counter + indexO(1)✗ (or tombstone)via optional 2nd indexO(page)yes1 (2 with the lookup index)
(b) double map + swap-popO(1)O(1)O(1)O(page)no2 (forward + reverse)
(c) events onlyO(1)O(1)n/a0

O(1) counts steps, not ledger I/O: the swap-and-pop above writes four entries for a non-tail removal (two for a tail removal), and every entry touched counts toward the per-transaction read and write limits. The ledger entries per item column is the rent side — the double map keeps two entries alive per item, roughly 2× the storage of Strategy 2. These write and rent costs are the reason to index only what the contract itself needs to list on-chain; when only off-chain consumers read the list, variant (c) is enough.

In the wild

Strategy 9: Pull, don't push — lazy settlement for unbounded holders

persistent · O(1) per user · cumulative index

You need. To distribute something (interest, rewards, emissions) continuously to an unbounded set of holders — without ever touching more than one holder per transaction.

The catch. The naive design is push-based: loop over every holder and update each one's entry. Current Mainnet settings allow 200 written entries per transaction, so the loop dies as soon as the population outgrows the per-transaction limit — and splitting it across transactions only scales so much. On the other hand, the design described here stays O(1) per user no matter how many holders exist.

global entry — cumulative rewards-per-share, only ever increasesindex: 1.0842… ↑ (advanced lazily when emissions are updated)UserEmis(alice)index: 0.9310 → 1.0842accrued: 12.40 → 27.72accrued += shares × Δindex — settles when alice shows upUserEmis(bob)untouched…× 10,000untouched

The pattern. pull-based approach: one global cumulative-index entry per pool plus one snapshot entry per user:

#[contracttype]
pub struct ReserveEmissionData { // ONE entry per reserve
pub index: i128, // cumulative rewards-per-share; only ever increases

// 👇 the fields below are used to advance the index lazily on any touch:
// index += eps * (now - last_time) / total_supply, until expiration
pub eps: u64,
pub expiration: u64,
pub last_time: u64,
}
#[contracttype]
pub struct UserEmissionData { // ONE entry per (user, reserve)
pub index: i128, // the global index last time this user was touched
pub accrued: i128,
}
// when settling one user: • accrued += user_shares * (global.index - user.index);
// • user.index = global.index;

The global index answers one question — how much has a single share earned since the pool began — and it only ever increases, bumped lazily whenever anyone interacts. Each user's snapshot records where that index stood at their last settlement, so global.index − user.index is exactly the rewards-per-share earned while they weren't looking. A holder who joins late starts at the current index and earns nothing for the time before; one who stays away for months loses nothing, because those two numbers reconstruct every accrual period they missed. Nobody ever iterates.

Trade-offs

  • O(1) entries touched per user action — the same cumulative-index pattern EVM DeFi knows as Synthetix's rewardPerTokenStored or MasterChef's accSushiPerShare.
  • Rounding, scaling, and index monotonicity are protocol invariants and need focused tests and review.
  • Nothing ever runs on a passive user's behalf: rewards keep accruing by formula and settle whenever the user finally shows up, but the pattern cannot push obligations (fees, penalties) onto accounts that never transact.
  • TTL pairing: Blend gives shared emission data a 45 → 46 day policy and per-user emission data a 100 → 120 day policy — the shared entry is bumped by every user's interaction, while a user's entry is bumped only when that user shows up, so it needs the longer lease.

In the wild

Strategy 10: Checkpoints — read state as of a past ledger

persistent · O(1) append · O(log n) historical read

You need. To read a value as of a past ledger, on-chain: a voter's power when a proposal snapshotted, total supply when voting started, the quorum in force back then. Tallying votes with current balances instead lets an attacker acquire tokens mid-vote, vote, and dump them.

The catch. A per-entity entry (Strategy 2) keeps only the latest value — every write destroys the history. Keeping the whole history as a growing Vec or Map under one key is the first red flag in the review checklist (64 KiB cap, whole-entry rewrites). And Strategy 9's cumulative index answers "how much accrued per share", not "what was this value at ledger X".

alice's voting power — one persistent checkpoint entry per changeidx 0idx 1idx 2idx 3ledger 100votes 40ledger 250votes 90ledger 400votes 75ledger 620votes 130NumCheckpoints(alice) → 4 — writes touch only the tail + countervoting power at ledger 500? → binary-search the indexes620 > 500 ✗400 ≤ 500 ✓last checkpoint at or before ledger 500 → 75 votes · each probe reads one entry

The pattern. Checkpoint the value: an append-only series of (ledger, value) snapshots per subject — Strategy 8(a)'s counter-plus-indexed-entries layout, applied through time:

#[contracttype]
pub struct Checkpoint { pub ledger: u32, pub votes: u128 }

#[contracttype]
pub enum VotesStorageKey {
NumCheckpoints(Address), // how many checkpoints a delegate has
DelegateCheckpoint(Address, u32), // index -> Checkpoint
}
// write: push a checkpoint at the current ledger and bump the counter — or
// overwrite the tail if it is already at this ledger (counter unchanged)
// read: binary-search 0..num for the last checkpoint with ledger <= target

Checkpoints before the tail are never rewritten — that is what makes binary search valid. And queries must target a strictly past ledger (OpenZeppelin rejects the rest with FutureLookup): the current ledger's value can still change before it closes.

Trade-offs

  • Appends are O(1), and same-ledger updates collapse into the tail checkpoint, so series length tracks activity, not call count. Reading the latest value takes two reads: the counter, then the tail checkpoint.
  • A historical read costs O(log n) ledger reads — each probe is its own footprint entry. Simulation declares them automatically, but they count toward the per-transaction disk-read cap.
  • Every checkpoint is a persistent entry paying rent, and one archived checkpoint in the search path fails the transaction until it is restored — keep the series' TTLs extended for as long as history can be queried (OpenZeppelin bumps every checkpoint it touches).
  • Checkpoint only what the contract itself must read historically; off-chain consumers can rebuild history from events (Strategy 8, variant c) at zero on-chain cost.

In the wild

Strategy 11: Merkle roots and derived state — replace storage with verification

one 32-byte root · derived IDs · sentinel values

You need. To handle large or numerous records — airdrop entitlements, governance proposals, scheduled operations — whose full contents would be expensive or impossible to keep on-chain.

The catch. Stored bytes add I/O, keeping them live costs rent, and each entry a transaction touches consumes footprint. None of that is mandatory: when callers are willing to hold the data themselves, the chain only needs enough of it to verify what they bring back.

bob, 1200carol, 90…10,000 moreh₂₃alice, 500h₀₁the full list lives off-chainto claim, alice brings her record + proof — the contract re-hashes her path back to the rootRootHash32-byte root in instance storage →Claimed(0)← one flag per actual claim

The pattern. Keep the records off-chain and store only a 32-byte hash that commits to them. Whoever wants the contract to act on a record must send that record back in; the contract re-hashes what it received and compares the result with the stored hash. A match proves the input is byte-for-byte the data that was committed to — so the contract can safely act on data it never stored.

Hashing a whole list into one commitment would force every claimer to resend the whole list. A Merkle tree fixes that: hash the records pairwise up to a single root, and any one record becomes provable on its own — the claimer sends their record plus one sibling hash per tree level (the proof), and the contract re-hashes upward — landing on the stored root proves the record is in the tree. The tree itself never touches the ledger; on-chain state for an arbitrarily large airdrop is one root plus one Claimed(index) flag per completed claim:

enum DataKey { RootHash, TokenAddress, Claimed(u32) }
// claim(index, receiver, amount, proof) -> re-hash (index, receiver, amount) up the
// proof path; if it lands on RootHash: set Claimed(index), transfer amount to receiver

For a single record, skip the tree: hash the record itself and use the hash as both fingerprint and storage key — a derived ID. OpenZeppelin's Governor never stores a proposal's actions: propose hashes them into proposal_id and stores only a four-field ProposalCore (proposer, vote snapshot, vote end, state) under that ID; to execute, the caller sends the full action list again, and finding a ProposalCore under the re-computed hash proves these are exactly the actions that were voted on — the contract invokes them straight from the caller's arguments:

// governor: proposal_id = hash(targets, functions, args, description_hash)
// timelock: operation_id = hash(target, fn, args, predecessor, salt)

What must stay on-chain can still shrink to a sentinel. The Governor's companion Timelock contract, which holds approved calls until a delay passes, keys its operations by the same kind of derived hash — and its entire record of an operation is a single u32: 0 means never scheduled, 1 means executed, and any other value is the ledger at which the operation becomes ready. Identity in the key, the whole lifecycle in one integer. The Governor itself never writes Pending, Active, Succeeded, or Defeated at all: it derives them from the stored voting window, the tallies, and the clock, and writes state only on irreversible transitions (canceled, queued, executed).

Trade-offs

  • The ledger can verify data it never stored, but it cannot return it — availability becomes someone else's job, and if the off-chain copy is lost the records are permanently unusable. Each variant answers this differently: a Merkle distributor must publish the tree somewhere durable (its own auth-gated API) so claimers can fetch their proofs, while the OZ governance contracts emit each proposal's or operation's full inputs in its creation event — a single record is small enough that event history can serve as the backup copy.
  • Scale is set by what you still write per record. One persistent Claimed(index) entry per claim grows without bound — that is OZ's merkle_distributor. The official merkle_distribution example keeps the flags inside the shared instance entry instead: simpler, but the whole distribution must then fit in one 64 KiB entry.
  • Verification itself is cheap: O(log n) hashes for a Merkle proof, a single hash for a derived ID.

In the wild

Strategy 12: Scale out — contract-per-entity via factory

one contract per entity · own instance & TTLs · factory registry

You need. To distribute growth across contracts — a DEX with many markets, a wallet per user, or a pool per risk profile — beyond what one contract should hold.

The catch. Even with a sound per-entry layout, one contract concentrates its instance state, upgrade surface, and failure domain. Transactions that write that instance entry conflict, and all state in it shares the 64 KiB entry cap.

factoryregistry + factory config0 → pair XLM/USDC1 → pair XLM/EURC2 → pair BTC/USDCpair XLM/USDCown instance · own TTLspair XLM/EURCown instance · own TTLspair BTC/USDCown instance · own TTLsindependent storage domains:pair-local entries do not conflict across pairs

The pattern. Deploy a contract per entity from a factory, which keeps the registry and shared config itself (Strategy 8 for the index, Strategy 2 for the reverse lookup). Each pair/pool/wallet gets its own instance storage, its own TTLs, its own parallelism domain:

// soroswap factory: deterministic deployment + dual registry
let pair_address = create_contract(&e, pair_wasm_hash, &token_pair); // deploys pair contract
put_pair_address_by_token_pair(&e, token_pair, &pair_address); // (tokenA,tokenB) -> addr
add_pair_to_all_pairs(&e, &pair_address); // index n -> addr

The deployment is deterministic: the salt hashes the sorted token pair, so a pair's address is recomputable from its tokens and the factory address alone — Strategy 11's derived-ID idea, applied to contract addresses.

Trade-offs

  • Isolation: pair-local entries for pair A do not conflict with pair-local entries for pair B. Transactions that touch shared token, router, or factory entries still contend.
  • Sharding buys parallelism, not headroom: network-wide per-ledger resource caps apply across all contracts combined.
  • Cross-entity operations become cross-contract calls (CPU + footprint per hop); a router contract usually papers over this — Soroswap's router holds exactly one storage key: the factory address.
  • Fleet upgrades are real operational work (N contracts to upgrade), and each instance needs its own TTL extensions. The upgrade half will be solvable by CAP-85 in protocol 28 — a beacon pattern: the fleet shares one externally managed executable, so a single update upgrades every instance.

In the wild

Decision path: composing strategies

For each piece of state, answer every question in order — steps 1–3 pick the lifecycle, steps 4–9 the layout. Several steps can match one piece of state, and the contract as a whole composes the strategies its pieces land on.

  1. Can you avoid storing it? Data that callers can hold and prove needs only a hash commitment on-chain; data derivable from other state should be recomputed; data read only by off-chain consumers can be emitted as events.
  2. Is it small, global, and read by (almost) every call? Keep it in instance storage, and keep the whole instance entry under a few KiB.
  3. Does its validity end at a known ledger, or is losing it acceptable? Use temporary storage: align the TTL with the deadline, and also store the deadline in the value so the contract can enforce it.
  4. Is it per-entity data with an unbounded population? Give each entity its own persistent entry, use composite keys when lookups need more than one dimension, and extend TTLs on access.
  5. Are several pieces usually read and written together? Pack them into one bounded entry; if they are updated independently or by different actors, split them into separate entries instead.
  6. Must the contract iterate over it? A small, admin-managed set fits in one bounded-collection entry; a large or user-managed set needs counter-plus-index entries, adding a reverse map with swap-and-pop when items can be removed.
  7. Are you distributing value across the population? Pull, don't push: keep one global cumulative index and settle each holder lazily when they show up.
  8. Must the contract read a value as of a past ledger? Checkpoint it: append (ledger, value) snapshots and binary-search them at read time.
  9. Is it still too big or too contended? Shard it: deploy one contract per entity from a factory.

Cheatsheet

Tier picker

DataTierWhy
Admin, config, token metadata, pause flaginstanceone entry loaded with each contract invocation
AMM reserves / totals (every swap updates them anyway)instanceco-located with instance; the write conflict is inherent to the product
Balances, ownership, positions, long-lived registriespersistentmust never be lost; archive + restore safety net
Voting power, quorum — anything read "as of ledger X"persistentappend-only checkpoint series; keep it live while queries can reach it
Allowances, approvals, auctions, epoch stats, pending proposalstemporarynatural deadline; half rent; deleted on TTL expiry
Oracle prices / regenerable cachestemporaryloss is cheap, freshness is the point
Anything derivable (states, IDs, tallies pre-first-vote)nonerecompute; hash-commit; lazy-create

Pattern → complexity

Every pattern in this guide, costed per operation — use it to compare finalists once the decision path has produced a shortlist. Complexity counts steps inside the contract, not ledger I/O: an O(1) swap-and-pop still writes up to four entries, and every entry touched counts toward the per-transaction read and write caps. Ledger entries per item is the rent side — how many entries the pattern keeps alive for each item it stores. An "n/a" cell means the pattern has no such operation.

PatternLookupInsertRemoveEnumerateLedger entries per itemStrategy
Instance singletonO(1)*O(1)O(1)n/a0 — shares the instance entryS1
Entry per entityO(1)O(1)O(1)needs an S8 index1S2 · S3
Bounded collection in one entryO(n) in-memO(1) + full rewriteO(n) + full rewriteO(n), 1 read1 for all n itemsS6
Packed struct per entityO(1), whole blobrewrite blobrewrite blobneeds an S8 index1S7
Counter + index entriesO(1) by indexO(1)append-onlyO(page)1 + shared counterS8 (a)
Double map + swap-and-popO(1)O(1)O(1)O(page), unordered2 — forward + reverseS8 (b)
Pull-based reward indexO(1) per usern/an/an/a1 per user + 1 globalS9
Checkpoint seriesO(1) latest · O(log n) pastO(1) appendn/aO(page) by index1 per checkpoint + shared counterS10
Merkle commitmentO(log n) verifyroot updateroot updatedata lives off-chain0–1 claim flag†S11
Factory / contract-per-entityO(1) + cross-contract calldeployregistry onlyvia registryits own contractS12

* Loaded with every invocation, whether or not the call reads it.

† Either packed into the shared instance entry (bounded) or one persistent entry per claim (unbounded).

🚩 Red flags in review

  • An unbounded Map or Vec under one key. It grows toward the 64 KiB entry cap, every update rewrites the whole value, and all writers contend on one entry. Give each item its own entry (S2); add an index only if the contract must enumerate (S8).
  • Variable-length data inside a key. The serialized ledger key is capped at 250 bytes, so a string or vector in the key can fail at runtime. Compose keys from addresses and integers (S3).
  • Entries created to store a default value. A stored zero pays rent to say nothing. Treat an absent entry as the default (S2).
  • Hot mutable data in instance() with many independent writers. Every write to the shared instance entry serializes those transactions, so ask whether the writes would conflict anyway: AMM reserves belong in instance because every swap must update them regardless of layout, while per-user balances are independent writes and belong in per-entity entries (S2).
  • Funds-critical data in temporary(). Expiry deletes it permanently; there is no restore. Anything the contract must not lose belongs in persistent storage (S4 covers what temporary is for).
  • TTL as the only expiry check. Anyone can extend any entry's TTL, so a TTL never enforces a deadline. Store the deadline in the value and check it in code (S4).
  • Persistent entries with no TTL-extension policy. Every entry archives once its initial TTL runs out, and a later transaction must pay rent and resource fees to restore it. Extend on access (S5).
  • A loop that updates every user. It dies at the 200-writes-per-transaction cap as soon as the population outgrows it. Keep one cumulative index and settle each user lazily (S9).
  • Data paged across entries because it outgrew 64 KiB. Paging is sometimes the right call, but it is often a symptom of a layout problem — first consider bounding the data (S6), splitting it by access pattern (S7), replacing it with a hash commitment (S11), or sharding by contract (S12).

Appendix: Mainnet limits

Protocol 27, checked 2026-07-20. Network validators can change these values — re-verify with stellar network settings --network mainnet or on Stellar Laboratory.

Per transactionLimitPractical meaning
CPU instructions400,000,000compute budget for the whole invocation tree
Memory40 MiBhost + guest memory
Footprint entries (read + write)400max distinct entries one transaction may touch
Disk-read entries / bytes200 / 200,000separate from the footprint cap; applies to disk-backed reads
Written entries / bytes200 / 132,096distinct entries written; sum of written entry sizes (~129 KiB)
Transaction size132,096 Benvelope incl. footprint — big footprints eat your payload
Events + return value16,384 Bcontract events plus the top-level return value in metadata
Per ledger (target ~5 s today)Limit
CPU instructions580,000,000
Disk-read entries / bytes1,000 / 400,000
Write entries / bytes1,000 / 286,720
Smart-contract transactions2,000
Soroban transaction bytes266,240 B
Sizes & TTLsValue
Max serialized contract-data ledger-key size250 B
Max contract-data entry size65,536 B (64 KiB)
Max contract Wasm size131,072 B (128 KiB)
Max entry TTL3,110,400 ledgers ≈ 180 d
Min TTL: persistent/instance create or restore2,073,600 ledgers ≈ 120 d
Min TTL: temporary create17,280 ledgers ≈ 1 d
info

Two numbers to remember: 17,280 ledgers is about one day at today's ~5-second target close time, and 64 KiB is the whole contract-data entry limit. Close time is a network setting, so day-based TTL constants are approximations, not wall-clock guarantees. Many codebases define DAY_IN_LEDGERS for readability. Keep entries well below the size limit because large values increase read, write, and rent costs.

Repositories reviewed

RepositoryWhat it isStorage patterns
stellar/soroban-examplesOfficial examplesPersistent balances, temporary allowances, instance AMM reserves and account signers, temporary epoch-bucketed mint quotas, and an instance-backed Merkle distributor
OpenZeppelin/stellar-contractsStandard libraryPersistent fungible balances, temporary allowances and role transfers, role-member and NFT swap-and-pop indexes, derived Governor/Timelock IDs, per-index Merkle claim flags, vote checkpoints, and a per-signer smart-account registry with packed context rules
blend-capital/blend-contracts-v2Lending protocol30-reserve list, packed positions, split reserve config/data, temporary auctions and queued configs, shared/user TTL gradient, and pool/backstop emissions
soroswap/coreAMMInstance pair reserves, dual-index factory registry, deterministic contract-per-pair deployment, and a router with the factory address in instance storage
reflector-network/reflector-contractPrice oracleTimestamp-keyed temporary price updates, a TTL derived from retention, and a bounded instance cache
kalepail/smart-account-kitPasskey smart-account SDKDeterministic per-user deployment of the OpenZeppelin smart account, with a salt derived from the credential ID
allbridge-public/allbridge-core-soroban-contractsCross-chain bridgeStorage-tier abstraction trait plus persistent sent/received message-hash flags
Phoenix-Protocol-Group/phoenix-contractsDEX, vesting, and stakingPer-user persistent bonding data plus persistent per-reward-token history maps
CometDEX/comet-contracts-v1Weighted AMMPersistent LP-token balances, temporary composite-key allowances, and a factory pool registry
FredericRezeau/soroban-kitMacro libraryType-safe storage macros that select instance, persistent, or temporary storage and bind key/value types

Methodology. Network values were read with stellar network settings --network mainnet and cross-checked against the Mainnet constants rendered by Stellar Laboratory on 2026-07-20. Repository claims above were checked against each repository's current default branch. Further reading: state archival · persisting data.