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.
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:
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
RestoreFootprintOpnaming the exact ledger keys; the CLI wraps this asstellar 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 in | the contract instance's own entry | its own entry per key | its own entry per key |
| On TTL expiry | the single instance entry is archived; contract code has its own TTL and may be archived separately | only that key's entry is archived | deleted forever |
| Automatic restoration | simulate and submit an invocation; RPC adds the archived instance and contract code, if needed, to the restore list | simulate and submit an invocation that accesses the key; RPC adds that exact key to the restore list | impossible |
| Manual restoration | submit RestoreFootprintOp for the instance and code ledger keys; stellar contract restore --id C... restores the instance | submit RestoreFootprintOp for that key; stellar contract restore --id C... --key ... wraps it | impossible |
| TTL extension | one instance call covers all instance data and also extends contract code | each key is extended separately | each key is extended separately |
| Rent | full rate on the whole instance entry | full rate per entry | half rate per entry |
| Loaded when | every invocation of this contract | only when in the footprint | only when in the footprint |
| Right for | small, global, contract-lifetime config | data you must never lose | data 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.
ExtendFootprintTTLOphas 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.