Versioned Enum Pattern
Suppose a contract storesDataV1 entries and is upgraded to use DataV2, which adds an optional field c:
V1 or V2 data struct.
Migration Logic
The migration logic enumerates the two data formats and convertsV1 data to V2 format, and passes V2 format through. If it’s already V1, it maps fields a and b over and sets the new c field to None (the field that was added in V2). If it’s already V2, it passes through unchanged. This is a lazy migration - old data is upgraded on read, not in a bulk migration.
Reading with version awareness
The value is read from storage and theninto_v2() ensures that the returned value is in the V2 format.
Writing always uses the current version
The write functionwrite_data() takes a data argument in the DataV2 format.
Testing migrations
Testing data migration requires simulating state written by an old contract version and verifying that the new contract reads it correctly. In this test data in theV1 format is first stored. Then it’s read using the read_data function, which converts data in the V1 format to V2 format with into_v2() before returning the result. The result is tested with assert_eq!(), and stored with the same id as it was stored with, which means the V1 formatted data is overwritten with the same data in V2 format.
Then the data is read from storage to verify it’s stored in the V2 format, and finally the data is read using the read_data() function to verify that the data is also returned in the V2 format by the read function.
Version Marker Pattern
An alternative solution is to store a version number alongside each data entry, keyed by the same identifier. The contract reads the version first, then branches on the result to decode the payload with the correct type.Key layout
Define two variants in your key enum - one for the version marker and one for the payload - both keyed by the sameid:
Reading with version awareness
Before decoding a storage entry, read its version marker. Useunwrap_or(1) to handle entries that were written before versioning was introduced. The absence of a version key is itself a signal that the entry is version 1:
Writing always uses the current version
Every write stamps the entry with the current version number. An entry that was originallyDataV1 will carry a DataVersion marker of 2 the next time it is written back:
Lazy vs eager migration
Once version-aware read/write logic is in place, there are two strategies for converting old entries.Lazy migration (convert on read)
In lazy migration, old entries are left untouched on the ledger. When a record is read, its version is detected and it is up-converted in memory. When that record is later written back, it is stamped with the new version. No explicit migration step is needed - conversion happens as records are accessed in normal contract use. Lazy migration is generally preferred on blockchains. Leaving old entries untouched has no upfront cost and no risk of hitting instruction or ledger-entry limits at upgrade time. Records that are never accessed again are never migrated, which is usually acceptable. Theread_data function shown above already implements lazy migration. Each time an old DataV1 entry is read and then passed to write_data, the entry is silently upgraded in place.
Eager migration (batch conversion)
In eager migration, an explicit admin function iterates all known records and rewrites them in the new format immediately after the upgrade is deployed:Testing migrations
Testing data migration requires simulating state written by an old contract version and verifying that the new contract reads it correctly. The Soroban test environment allows you to set storage state directly. Use this to writeDataV1 entries (without a DataVersion key) and verify that read_data up-converts them correctly:
- A
DataV1entry with no version marker (pre-versioning era records) - A
DataV2entry written by the new contract - A
DataV1entry that is read and then written back (the lazy migration round-trip)
Why intuitive approaches fail
The techniques presented here may not immediately seem necessary. The “apparent” obvious solutions may be to programmatically handle the discrepancies in data types, rather than modify any of the underlying data structures, or adjust how the storage entries are read or written.Approach 1: Read old entries directly with the new type
You may think the most natural approach is to read the stored bytes directly asDataV2 and expect c to default to None:
Error(Object, UnexpectedSize). The Soroban host validates the field count of the XDR-encoded value against the type definition before returning anything to the contract. Because DataV1 has two fields and DataV2 has three, the host rejects the entry before the SDK can handle it.
Approach 2: Use try_from_val as a fallback
Another approach is to use try_from_val expecting to catch a deserialization error and recover:
Err that the SDK can intercept. There is no way to catch or recover from the mismatch at the contract level.
The root issue is that a contract cannot determine which type an existing storage entry was written as just by reading it. That information must be stored explicitly.