Package Upgrades
As we mentioned in the Packages concept, published packages are immutable - the bytecode stored onchain can never be modified or deleted. Yet real applications need to evolve: bugs get fixed, features get added, and dependencies move forward. Sui reconciles these two requirements with package upgrades - a way to publish a new version of a package while keeping every previous version intact.
This section explains the mechanics: what an upgrade can and cannot change, the UpgradeCap object that authorizes upgrades, and - most importantly - what upgrades mean for the state your package has already created. For design advice on writing upgrade-friendly code, see the Upgradeability Practices guide.
An Upgrade Is a New Package
An upgrade does not touch the published bytecode. Instead, it publishes the new version of the code at a new address and records it as the successor of the previous version. Both versions - in fact, all versions ever published - remain onchain side by side:
0xAAA... <- version 1, published
0xBBB... <- version 2, upgrade of 0xAAA
0xCCC... <- version 3, upgrade of 0xBBB, the latest version
This has a consequence that is easy to miss: old versions of a package remain callable. An upgrade does not redirect anyone - a transaction can still call functions of version 1 directly, and packages that depend on version 1 keep calling version 1 until they upgrade their own dependency. Publishing a fix does not, by itself, stop the buggy version from being used. We will return to this point when we talk about state.
Types, however, are not duplicated across versions. A struct keeps the identity of the package version that first defined it: a Counter type from version 1 is exactly the same type in version 2, and objects created before the upgrade are fully compatible with the new code. A type first added in version 2 belongs to version 2, and so on.
What Can Change
An upgraded package must stay compatible with the previous version, so that existing callers and dependent packages don't break. Under the default - most permissive - upgrade policy, an upgrade can:
- change the implementation of any function;
- add new modules, functions, and types;
- change, add, or remove public(package), private, and non-public entry functions;
- change dependencies.
And it can not:
- remove a module;
- change or remove the signature of a public function;
- change or remove an existing type definition - fields, abilities, and type parameters of every struct and enum are frozen forever, whether public or not.
In short: public signatures and data layouts are permanent, implementations are not. This is why the Upgradeability Practices guide recommends keeping public surface minimal and structs thin - every public function and every struct field is a commitment for the lifetime of the package.
The UpgradeCap
When a package is published, the Publish command returns an UpgradeCap - an object defined in the sui::package module of the Sui Framework. It is a classic capability: whoever owns it can upgrade the package, and no one else can.
module sui::package;
/// Capability controlling the ability to upgrade a package.
public struct UpgradeCap has key, store {
id: UID,
/// (Mutable) ID of the package that can be upgraded.
package: ID,
/// (Mutable) The number of upgrades that have been applied
/// successively to the original package. Initially 0.
version: u64,
/// What kind of upgrades are allowed.
policy: u8,
}
The package field always points at the latest version - only the latest version of a package can be upgraded, so the chain of versions never forks. The upgrade itself is a three-step dance inside a single transaction: authorize_upgrade takes the UpgradeCap and returns an UpgradeTicket; the Upgrade transaction command consumes the ticket, verifies and publishes the new bytecode, and returns an UpgradeReceipt; finally, commit_upgrade applies the receipt back to the UpgradeCap. Both the ticket and the receipt are hot potatoes - they cannot be stored or dropped, so an authorized upgrade cannot be left half-finished. In practice the whole flow is built for you by the sui client upgrade CLI command.
The policy field stores the most permissive kind of upgrade the capability allows. It starts at compatible - the default policy described above - and can be restricted to additive (only new functionality can be added, existing code is frozen) or dependency-only (only dependencies can be changed). Restriction is a one-way street: only_additive_upgrades and only_dep_upgrades can tighten the policy, but nothing can loosen it back. And because authorize_upgrade is a regular public function taking the UpgradeCap, the capability can be wrapped in a custom object to enforce arbitrary upgrade rules - a timelock, a multisig, or a vote.
Making a Package Immutable
The final restriction is giving up upgrades altogether. Deleting the UpgradeCap makes the package truly immutable - no one will ever be able to publish a new version:
/// Discard the `UpgradeCap` to make a package immutable.
public entry fun make_immutable(cap: UpgradeCap) {
let UpgradeCap { id, package: _, version: _, policy: _ } = cap;
id.delete();
}
This is irreversible, and that is exactly the point: it is the strongest guarantee a package can offer. Users and dependent packages know the code they reviewed is the code that will run forever. The trade-off is equally permanent - no bug can ever be fixed. Immutability is a common choice for small foundational libraries, and a dangerous one for evolving applications.
Upgrades and State
Objects are stored outside of packages, and an upgrade does not touch them: a shared object created by version 1 is just as accessible to version 1 as it is to version 2. Combined with the fact that old versions remain callable, this leads to the central problem of upgrades: without explicit versioning, the old code keeps full access to the state. If version 2 fixes a bug in a function that mutates a shared object, an attacker can simply keep calling the version 1 function - on the very same object.
The solution is to version the state itself. The object carries a version field, the package carries a VERSION constant, and every function that touches the object first checks that the two match:
module book::versioned_state;
/// The version of the package this module belongs to. Incremented on
/// every upgrade that has to invalidate previous versions.
const VERSION: u8 = 2;
/// Trying to use an object with an older or newer package version.
const EVersionMismatch: u64 = 0;
/// Shared state of the application; the `version` field gates access,
/// tying the object to a single version of the package.
public struct Counter has key {
id: UID,
version: u8,
value: u64,
}
/// Every function that uses the shared object starts with a version
/// check: only the package version stored in the object may proceed.
public fun increment(counter: &mut Counter) {
assert!(counter.version == VERSION, EVersionMismatch);
counter.value = counter.value + 1;
}
Constants are baked into the bytecode, so each published version compares the object against its own number: version 1 bytecode checks for 1, version 2 bytecode checks for 2. As long as the object's field says 1, the old code keeps working and the new code aborts - and the moment the field is bumped to 2, the situation flips: every call into the old version aborts with EVersionMismatch, and only the latest code can proceed. Bumping the version is how the old package is decommissioned.
Migrating State
The version bump - the migration - can be performed in two ways, and the choice depends on how much state there is and who can reach it.
The straightforward way is an eager migration: right after the upgrade, the holder of an admin capability calls a migrate function which bumps the version of the shared object in a single transaction:
/// Grants the holder the permission to migrate the shared state.
public struct AdminCap has key, store { id: UID }
/// The object is already migrated to the current version.
const ENotUpgrade: u64 = 1;
/// Bump the version of the shared object, so that only the current
/// package version can use it. Called by the admin after an upgrade.
public fun migrate(counter: &mut Counter, _: &AdminCap) {
assert!(counter.version < VERSION, ENotUpgrade);
counter.version = VERSION;
}
Eager migration is a clean cut-over and is the right choice when the state is a handful of shared objects the publisher controls. It falls short when it can't reach everything: an application may have thousands of objects, or the objects may be owned by users - and only the owner can send a transaction touching an owned object.
For these cases there is lazy migration: instead of migrating everything up front, each object is migrated the first time the new code touches it. This is also the answer to a limitation we saw earlier - struct layouts can never change, so how does state evolve at all? By keeping the base object thin and storing the actual content in a dynamic field, which can be swapped for a new shape at any time:
module book::versioned_config;
use sui::dynamic_field as df;
/// The current version of the package.
const VERSION: u8 = 2;
/// The base object stays thin - its layout can never change in an
/// upgrade. The actual configuration is attached as a dynamic field.
public struct Config has key {
id: UID,
version: u8,
}
/// Configuration attached as a dynamic field in version 1.
public struct ConfigV1 has store { fee: u64 }
/// Version 2 of the configuration adds a new field.
public struct ConfigV2 has store { fee: u64, discount: u64 }
/// Read the fee, migrating the configuration on first access.
public fun fee(config: &mut Config): u64 {
config.migrate_if_needed();
df::borrow<u8, ConfigV2>(&config.id, 0).fee
}
/// Replace `ConfigV1` with `ConfigV2` the first time the object is
/// used after the upgrade.
fun migrate_if_needed(config: &mut Config) {
if (config.version == 1) {
let ConfigV1 { fee } = df::remove(&mut config.id, 0u8);
df::add(&mut config.id, 0u8, ConfigV2 { fee, discount: 0 });
config.version = VERSION;
}
}
Version 1 of this package attached a ConfigV1 to the object; version 2 defines a richer ConfigV2 and quietly replaces the old value on first access. No coordinated migration is needed - objects upgrade themselves as they are used, whether there are ten of them or ten million, owned or shared.
Summary
- An upgrade publishes a new version of a package at a new address; all previous versions stay onchain and remain callable.
- Compatibility rules protect callers: implementations can change and new code can be added, but public function signatures and type definitions are permanent.
- The UpgradeCap is the capability authorizing upgrades; its policy can be restricted one way - from compatible to additive to dependency-only - and deleting it via make_immutable makes the package immutable forever.
- State is not part of the package: without explicit versioning, old versions keep full access to shared objects. A version field checked against a package VERSION constant decommissions old code.
- Migrations can be eager - an admin bumps the version right after the upgrade - or lazy - each object migrates on first access, which also allows evolving the shape of the state through dynamic fields.
Further Reading
- Upgradeability Practices guide on designing upgrade-friendly packages.
- Package Upgrades in the Sui documentation.
- Custom Upgrade Policies in the Sui documentation.