Understand how Sui stores objects, manages storage costs, and provides efficient data access.
Sui’s storage model is designed around objects, with each object stored independently and accessed through its unique ID. This object-centric approach enables parallel execution and efficient state management.
Objects are stored as BCS (Binary Canonical Serialization) bytes:
pub struct MoveObject { /// The type of this object. Immutable type_: MoveObjectType, /// DEPRECATED this field is no longer used to determine whether a tx can transfer this /// object. Instead, it is always calculated from the objects type when loaded in execution has_public_transfer: bool, /// Number that increases each time a tx takes this object as a mutable input /// This is a lamport timestamp, not a sequentially increasing version version: SequenceNumber, /// BCS bytes of a Move struct value contents: Vec<u8>,}
The first 32 bytes of an object’s contents are always its ID:
/// Index marking the end of the object's ID + the beginning of its versionpub const ID_END_INDEX: usize = ObjectID::LENGTH;pub fn id(&self) -> ObjectID { Self::id_opt(&self.contents).unwrap()}pub fn id_opt(contents: &[u8]) -> Result<ObjectID, ObjectIDParseError> { if ID_END_INDEX > contents.len() { return Err(ObjectIDParseError::TryFromSliceError); } ObjectID::try_from(&contents[0..ID_END_INDEX])}
This predictable layout allows Sui to efficiently extract object IDs without deserializing the entire object.
/// Struct representing the storage fund, containing two `Balance`s:/// - `total_object_storage_rebates` has the invariant that it's the sum of `storage_rebate` of/// all objects currently stored on-chain. To maintain this invariant, the only inflow of this/// balance is storage charges collected from transactions, and the only outflow is storage rebates/// of transactions, including both the portion refunded to the transaction senders as well as/// the non-refundable portion taken out and put into `non_refundable_balance`./// - `non_refundable_balance` contains any remaining inflow of the storage fund that should not/// be taken out of the fund.public struct StorageFund has store { total_object_storage_rebates: Balance<SUI>, non_refundable_balance: Balance<SUI>,}
let bound = if protocol_config.allow_unbounded_system_objects() && system_mutation { if contents.len() as u64 > protocol_config.max_move_object_size() { debug_fatal!( "System created object (ID = {:?}) of type {:?} and size {} exceeds normal max size {}", MoveObject::id_opt(&contents).ok(), type_, contents.len(), protocol_config.max_move_object_size() ); } u64::MAX} else { protocol_config.max_move_object_size()};
System objects can exceed normal size limits when allow_unbounded_system_objects is enabled.
pub fn new_gas_coin(version: SequenceNumber, id: ObjectID, value: u64) -> Self { unsafe { Self::new_from_execution_with_limit( GasCoin::type_().into(), true, version, GasCoin::new(id, value).to_bcs_bytes(), 256, ) .unwrap() }}/// Return the `value: u64` field of a `Coin<T>` type./// Useful for reading the coin without deserializing the object into a Move valuepub fn get_coin_value_unsafe(&self) -> u64 { debug_assert!(self.type_.is_coin()); // 32 bytes for object ID, 8 for balance debug_assert!(self.contents.len() == 40); u64::from_le_bytes(<[u8; 8]>::try_from(&self.contents[ID_END_INDEX..]).unwrap())}/// Update the `value: u64` field of a `Coin<T>` type.pub fn set_coin_value_unsafe(&mut self, value: u64) { debug_assert!(self.type_.is_coin()); // 32 bytes for object ID, 8 for balance debug_assert!(self.contents.len() == 40); self.contents.splice(ID_END_INDEX.., value.to_le_bytes());}
Why Coins are Special
Coins always have a fixed 40-byte structure:
32 bytes: Object ID
8 bytes: Balance (u64)
This allows Sui to read and update coin balances without full deserialization, significantly improving performance for common operations like transfers and merges.
Dynamic fields are stored as separate child objects:
/// Internal object used for storing the field and valuepublic struct Field<Name: copy + drop + store, Value: store> has key { /// Determined by the hash of the object ID, the field name value and it's type, /// i.e. hash(parent.id || name || Name) id: UID, /// The value for the name of this field name: Name, /// The value bound to this field value: Value,}
For large collections, use dynamic fields or Table:
use sui::table::{Self, Table};public struct GameWorld has key { id: UID, // Each player stored as separate object via Table players: Table<address, PlayerData>,}
For nested structures, use object wrapping or dynamic object fields:
public struct Company has key { id: UID, name: String,}public struct Department has key, store { id: UID, company_id: ID, employees: vector<address>,}public fun add_department(company: &mut Company, dept: Department) { dynamic_object_field::add(&mut company.id, dept.name, dept);}
Sui maintains a complete version history of all objects:
/// Sets the version of this object to a new value which is assumed to be higher (and checked to/// be higher in debug mode).pub fn set_version(&mut self, version: SequenceNumber) { #[cfg(debug_assertions)] { let prev_version = self.version; if prev_version >= version { panic!( "Cannot set version backwards: {} -> {}", prev_version, version ); } } self.version = version;}
public fun total_object_storage_rebates(self: &StorageFund): u64 { self.total_object_storage_rebates.value()}public fun total_balance(self: &StorageFund): u64 { self.total_object_storage_rebates.value() + self.non_refundable_balance.value()}
The storage fund balances:
Total Object Storage Rebates: Sum of all storage rebates for active objects
Non-Refundable Balance: Accumulated fees that fund long-term storage