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.