Skip to main content
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.

Storage Architecture

Sui uses a versioned object storage model where:

Object-Based

Each object is stored independently with its own version

Content-Addressed

Objects are referenced by ID and version number

Permanent History

All object versions are retained for historical queries

Direct Access

Objects can be read directly without scanning

Object Serialization

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>,
}

Object Layout

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 version
pub 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.

Storage Costs

Sui implements a storage fund mechanism to ensure sustainable long-term storage:

Storage Rebate System

From the storage fund implementation:
/// 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>,
}

How Storage Charges Work

When you create an object, you pay for its storage:
// Storage cost is charged based on object size
storage_cost = object_size_bytes * storage_price_per_byte
This cost is held in the storage fund as a rebate for when the object is deleted.
A small portion of the storage rebate is non-refundable and goes to the storage fund to ensure long-term sustainability.

Object Size Limits

Sui enforces maximum object sizes to ensure efficient processing:
pub unsafe fn new_from_execution_with_limit(
    type_: MoveObjectType,
    has_public_transfer: bool,
    version: SequenceNumber,
    contents: Vec<u8>,
    max_move_object_size: u64,
) -> Result<Self, ExecutionError> {
    if contents.len() as u64 > max_move_object_size {
        return Err(ExecutionError::from_kind(
            ExecutionErrorKind::MoveObjectTooBig {
                object_size: contents.len() as u64,
                max_object_size: max_move_object_size,
            },
        ));
    }
    Ok(Self {
        type_,
        has_public_transfer,
        version,
        contents,
    })
}

System Object Exception

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.

Optimized Storage for Coins

Coins have a special optimized storage format:
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 value
pub 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());
}
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 and Storage

Dynamic fields are stored as separate child objects:
/// Internal object used for storing the field and value
public 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,
}

Storage Benefits

Bypass Size Limits

Store unlimited data by splitting across dynamic fields

Efficient Updates

Modify individual fields without touching parent object

Selective Loading

Load only the fields you need

Granular Rebates

Receive storage rebates when removing fields

Storage Patterns

Small Objects

For small, frequently accessed data, use direct struct fields:
public struct Profile has key {
    id: UID,
    name: String,
    avatar_url: String,
    created_at: u64,
}
Best for: User profiles, NFT metadata, configuration

Large Collections

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>,
}
Best for: Leaderboards, registries, mappings

Hierarchical Data

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);
}
Best for: Organizations, complex game structures

Object Versioning

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;
}

Querying Historical State

You can query any past version of an object if you know its version number. This is useful for:
  • Auditing changes
  • Replaying transactions
  • Historical analysis
  • Debugging

Storage Best Practices

Smaller objects mean lower storage costs:
// Bad: Storing redundant data
public struct BadNFT has key {
    id: UID,
    owner: address,  // Redundant - already tracked by Sui
    name: String,
    description: String,
    full_image_data: vector<u8>,  // Store off-chain instead
}

// Good: Lean on-chain storage
public struct GoodNFT has key {
    id: UID,
    name: String,
    description: String,
    image_url: String,  // Reference to off-chain storage
}
Choose the right structure for your access patterns:
  • Direct fields: Fast, fixed-size data
  • Dynamic fields: Flexible, variable-size data
  • Table: Large key-value collections
  • Bag: Heterogeneous collections
Delete objects when no longer needed to recover storage rebates:
public fun cleanup(old_obj: OldObject) {
    let OldObject { id, .. } = old_obj;
    id.delete();  // Recover storage rebate
}
For large data (images, documents), store only URLs or hashes on-chain:
public struct Document has key {
    id: UID,
    title: String,
    ipfs_hash: String,  // Content stored on IPFS
    created_at: u64,
}

Storage Fund Economics

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

Objects

Understand the object model

Dynamic Fields

Extend objects at runtime

Gas Mechanism

Learn about gas and storage costs

Build docs developers (and LLMs) love