When should I use shared objects versus owned objects?
November 8, 2026
Use an address-owned object when a single address controls the state: a user's NFT, their coins, an admin capability. Owned objects skip consensus ordering, so they have the lowest latency and never contend with other users. Use a shared object when many different users must read and write the same state, such as a liquidity pool, an order book or a game board. Shared objects go through consensus, which adds some latency and serializes writes to that object, but needs no offchain coordination.
The trade-off
| Address-owned | Shared | |
|---|---|---|
| Who can use it | Only the owner's transactions | Anyone; your Move code enforces permissions |
| Versioning | Sender names the exact version (fastpath) | Consensus assigns versions |
| Latency | Lowest | Higher, and grows with contention |
| Concurrency | One in-flight transaction per object | Many in flight; writes are executed in order |
| Reversible | Can be transferred, wrapped, frozen | Sharing is permanent |
Rules of thumb
- Default to owned. If only one party ever needs to touch an object, keep it address-owned. Pass it by value or by
&mutin that user's transactions. - Share only the part that's truly global. A marketplace can share a small registry object and keep each listing or receipt as its own object.
- Use capabilities for admin rights. Gate privileged functions on a shared object with an owned
AdminCapargument, not a hardcoded address. - Avoid hot spots. All writes to one shared object in a checkpoint run one after another. If write throughput matters, split the state across several shared objects, for example sharding an order book by price range.
- Read-only data should be immutable, not shared. Frozen objects need no versioning, and anyone can read them in parallel.
Example: a shared counter guarded by an owned cap
module example::counter;
public struct Counter has key { id: UID, value: u64 }
public struct ResetCap has key { id: UID }
fun init(ctx: &mut TxContext) {
transfer::share_object(Counter { id: object::new(ctx), value: 0 });
transfer::transfer(ResetCap { id: object::new(ctx) }, ctx.sender());
}
/// Anyone can increment: this is why Counter is shared.
public fun increment(c: &mut Counter) {
c.value = c.value + 1;
}
/// Only the holder of the owned ResetCap can reset.
public fun reset(_: &ResetCap, c: &mut Counter) {
c.value = 0;
}The runtime does no access control on shared objects. Every privileged function has to check something: a capability, ctx.sender(), or a field stored in the object.
Pitfalls with owned objects
- Equivocation. If you submit two transactions at once that use the same owned object version (often the gas coin), one may be rejected, or the object can be equivocated and stay unusable until the next epoch. Run one transaction per owned object at a time, put related steps in a single PTB, or use separate gas coins. See Why do I get object version errors?.
- Stale versions. A cached object reference fails after the object changes. Fetch the latest version before you build each transaction (the SDK does this when you pass
tx.object(id)). - Multi-party flows need a coordinator. Docs.sui.io implements the same escrow swap both ways. The owned version needs a trusted custodian to match the two sides. The shared version doesn't, but every access is sequenced.
A middle ground: party objects
If you want single-address ownership but need several transactions in flight against one object, transfer it to a single-owner party (transfer::public_party_transfer with party::single_owner). It keeps owner-only access and is sequenced by consensus like a shared object. There's no extra cost if the transactions already touch shared objects. See What kinds of object ownership does Sui have?.
Sources
- Types of Object Ownership, Sui documentation
- Shared Objects, Sui documentation
- Owned Compared With Shared Objects, Sui documentation
- Object Versioning, Sui documentation
- Epochs, Sui documentation