Zero-copy accounts
Anchor's default Account<'info, T> deserializes the entire account into a Rust struct on the stack at the start of every handler, then serializes the struct back to the account at the end. For small accounts this is fine. For large accounts it is the bottleneck: stack frames blow up, compute units burn just shuffling bytes, and code size grows from monomorphization. Zero-copy is the alternative. You point a struct at the raw bytes and work in place. No copy in, no copy out.Why the default is sometimes the bottleneck
The default Anchor account treatment is a value type. When the handler runs, the framework reads the account's bytes, deserializes them into a freshly constructed T, and gives you a &mut T pointing at that struct. The struct lives on the stack, or on the heap if you wrap it in Box. At the end of the handler, the struct is serialized back into the account's data buffer.
This is convenient. It's also expensive when the struct gets large. Three real costs add up:
The stack has a hard limit. Each Solana instruction handler runs with a stack frame around 4 KB. A T that's 5 KB or 10 KB can't physically fit. The program won't even compile in some cases, and when it does compile, it can stack overflow at runtime. The error message is unhelpful and the cause is non-obvious.
Compute units burn on the copy. Every byte deserialized and re-serialized costs CU. For a small account that's a few dozen CU you don't notice. For a 50 KB account it's tens of thousands of CU per call, every call, for nothing more than copying bytes you already have.
Code size grows from monomorphization. The serializer is generic. Every type Anchor sees produces another copy of the deserialize and serialize code in your binary. Ten different large account types make your program binary substantially larger, which costs deploy SOL and CU on every load.
For accounts under a kilobyte or two, none of this matters. For accounts above a few kilobytes, all of it matters at once.
What zero-copy actually changes
Zero-copy reframes the account from a value type to a reference type. You don't get a copy of the data, you get a pointer into the data. Reads happen against the original buffer, writes happen against the original buffer, and there is no deserialize-then-serialize round trip.
The cost of this is discipline. The runtime cannot just cast a byte buffer to any Rust struct and trust the result. It only works if the struct's in-memory layout is identical to the on-chain byte layout. That means a known field order, no padding the compiler might insert, and no fields whose representation depends on something the compiler computes at runtime. The struct must be Plain Old Data, or POD.
The Pod constraint
To use zero-copy on a struct, the struct must satisfy the bytemuck::Pod and bytemuck::Zeroable traits. Anchor expresses this through #[account(zero_copy)] and enforces it at compile time. In practice, Pod requires:
#[repr(C)]on the struct, so the compiler doesn't reorder fields.- Every field type is itself Pod: primitive integers, fixed-size arrays of Pod types, other
#[repr(C)]structs of Pod fields. Pubkey and bool count as Pod. - No
Vec, noString, noHashMap, noOption<T>where the niche optimization changes layout. - No enum with payload. A fieldless enum is debatable, and the safest path is to use a plain
u8and document the meaning. - No references, no boxed pointers, no heap allocation of any kind.
Here's what a zero-copy struct looks like next to a regular one. The regular Account struct has String and Vec because Anchor's serializer can handle them:
// Regular account, fine for small/medium sizes
#[account]
pub struct Config {
pub admin: Pubkey,
pub name: String, // OK here, expensive in zero-copy world
pub fee_bps: u16,
}
// Zero-copy account, fixed layout
#[account(zero_copy)]
#[repr(C)]
pub struct OrderBook {
pub market: Pubkey,
pub head: u32,
pub tail: u32,
pub orders: [Order; 1000], // fixed-size array, fine
}
#[zero_copy]
#[repr(C)]
pub struct Order {
pub owner: Pubkey,
pub price: u64,
pub size: u64,
}The cost of the constraint is real. You can't have a dynamically-sized message field. You can't have a Vec of orders that grows. Every collection has a fixed capacity, and you manage the count yourself with a head, tail, or len field. The discipline is the price of speed.
When zero-copy is the right call
Most accounts in most programs should stay on the default Account<T>. Reaching for zero-copy is a deliberate decision driven by the size of the account.
The shape of an account that wants zero-copy is consistent. Big. Mostly fixed-shape data. Touched often, in performance-sensitive paths. Order books fit. Match buffers fit. Large bitmap state for an allowlist fits. Large position arrays for a perps protocol fit. Ring buffers for time-series fit.
The shape of an account that does NOT want zero-copy is the opposite. Small. Variable-length text or metadata fields where a String is natural. Modified rarely. The Config account in any program almost always stays on Account<T> because it's small and the convenience of having String fields is worth more than the copy cost on the rare update.
The middle case is Box<Account<T>>. Same serialization as the default, same String and Vec support, but the deserialized struct lives on the heap instead of the stack. This solves stack overflow problems while preserving Anchor's convenience. If your account is in the 2 KB to 8 KB range, has a few variable-length fields, and is not in your hottest path, Box<Account<T>> is often the right answer. You write Box<Account<'info, MyState>> in the Accounts struct, and everything else stays the same.
Working with AccountLoader
Zero-copy accounts are reached through AccountLoader<'info, T>, not Account<'info, T>. The loader has three methods that matter, each with different runtime semantics.
A worked example. The handler initializes a fresh order book:
#[derive(Accounts)]
pub struct InitMarket<'info> {
#[account(
init,
payer = payer,
space = 8 + std::mem::size_of::<OrderBook>(),
seeds = [b"book", market.key().as_ref()],
bump,
)]
pub order_book: AccountLoader<'info, OrderBook>,
/// CHECK: market identifier, validated by seeds
pub market: UncheckedAccount<'info>,
#[account(mut)]
pub payer: Signer<'info>,
pub system_program: Program<'info, System>,
}
pub fn init_market(ctx: Context<InitMarket>) -> Result<()> {
let mut book = ctx.accounts.order_book.load_init()?;
book.market = ctx.accounts.market.key();
book.head = 0;
book.tail = 0;
// book.orders is already all-zeros from load_init's wipe
Ok(())
}Three things to notice. The space calculation uses std::mem::size_of::<OrderBook>() because the struct's size is exact and known at compile time. There's no InitSpace macro here, because Pod structs have no variable-length fields and the standard size operator works perfectly. The handler calls load_init() once and then writes into the returned RefMut as if it were a regular &mut OrderBook. The borrow is held for the rest of the function and is automatically released when book goes out of scope.
A read-only handler that scans the book looks like:
pub fn best_bid(ctx: Context<ReadMarket>) -> Result<u64> {
let book = ctx.accounts.order_book.load()?;
let mut best = 0u64;
for i in book.head..book.tail {
let order = &book.orders[i as usize % book.orders.len()];
if order.price > best {
best = order.price;
}
}
Ok(best)
}load() returns Ref<T> instead of RefMut<T>. Multiple read borrows can coexist, so multiple instructions in the same transaction can read the same book simultaneously without conflict. Write borrows are exclusive: only one load_mut() can be live for a given account at a time.
The borrow rules matter most when a handler calls into other code while still holding a borrow. If you call a CPI while a RefMut is alive, and the called program tries to touch the same account, the runtime panics. The fix is to drop the borrow before the CPI:
{
let mut book = ctx.accounts.order_book.load_mut()?;
book.orders[idx].size = new_size;
// book is dropped at the end of this block
}
some_cpi_call(ctx.accounts.order_book.to_account_info(), ...)?;Scope the RefMut to the smallest region that needs write access, drop it before any CPI, and the borrow checker stops biting.
Box as the middle ground
If your account is too big for the stack but you don't want the Pod discipline, Box<Account<'info, T>> is the right answer. It works exactly like Account<'info, T> from your code's perspective: you read fields, mutate them, and Anchor serializes back at the end. The only difference is that the deserialized struct lives on the heap. Stack overflows stop being a concern. The copy still happens on every call, so the CU cost stays, but for accounts in the low-kilobyte range that cost is manageable.
The signature looks like this:
#[derive(Accounts)]
pub struct UpdatePositions<'info> {
#[account(mut, has_one = user)]
pub state: Box<Account<'info, UserState>>,
pub user: Signer<'info>,
}Everything else, including the body of the handler, is unchanged. state.positions[0].size = new_size; works the same way it would for Account<'info, UserState>. The only thing the Box changed is where the struct lives in memory.
This is the option most programs reach for first when they hit a stack overflow. If Box<Account<T>> still gives unacceptable CU costs, then it's time to migrate to zero-copy and live with the Pod constraints. The order is: default → Box → zero-copy, and you only move to the next step when measurements force you to.
What you actually do day to day
For 90% of accounts you'll write, the default Account<'info, T> is correct and you never think about this. For accounts large enough to stack-overflow but small enough that the copy is cheap, wrap in Box. For accounts that are both large and on a hot path, reach for AccountLoader<'info, T> with a Pod struct.
When you do reach for zero-copy, the discipline becomes part of the design: every field is fixed-size, every collection has a capacity you commit to up front, and you manage occupancy with explicit head/tail/len fields. The struct's memory layout is your contract with the on-chain bytes, and you don't get to change it after deployment without a careful migration. In exchange you get the kind of performance that makes high-frequency on-chain workloads possible: order books that process thousands of operations per second, perps protocols with hundreds of positions per user, anything where the account itself is the data structure rather than a wrapper around one.