Skip to main content
Zero-copy deserialization allows programs to read and write account data directly from memory without copying or deserializing it. This is essential for handling large accounts efficiently on Solana.

Why Use Zero-Copy?

Traditional Anchor accounts (Account<T>) copy data from the account into a heap-allocated struct during deserialization. This has significant limitations: Limitations:
  • Stack Limit: 4KB maximum
  • Heap Limit: 32KB maximum
  • Compute Cost: Deserialization consumes compute units proportional to data size
  • Memory Overhead: Data is duplicated in memory
Zero-Copy Benefits:
  • Direct Access: Casts raw account bytes to struct type (no copying)
  • Larger Accounts: Supports accounts up to 10MB (10,485,760 bytes)
  • Lower Compute: ~90% reduction in CU usage for large accounts
  • In-Place Updates: Modifies account data directly

Performance Comparison

Account SizeAccount<T>AccountLoader<T>Improvement
1 KB~8,000 CU~1,500 CU81% faster
10 KB~50,000 CU~5,000 CU90% faster
100 KBToo large~12,000 CUPossible
1 MBImpossible~25,000 CUPossible

When to Use Zero-Copy

Use Zero-Copy For

  • Accounts larger than 1KB
  • Arrays with many elements (orderbooks, event queues)
  • High-frequency read/write operations
  • Compute-sensitive programs
  • Fixed-size data structures

Use Regular Account<T> For

  • Small accounts (< 1KB)
  • Dynamic data structures (Vec, String, HashMap)
  • Frequently changing schemas
  • Simple state that doesn’t need optimization

Basic Usage

1. Add Bytemuck Dependency

Add bytemuck to enable zero-copy features:
Cargo.toml
[dependencies]
anchor-lang = "0.32.1"
bytemuck = { version = "1.20.0", features = ["min_const_generics"] }
The min_const_generics feature allows working with arrays of any size.

2. Define a Zero-Copy Account

Use the #[account(zero_copy)] attribute:
use anchor_lang::prelude::*;

#[account(zero_copy)]
pub struct Data {
    pub data: [u8; 10232],  // 10240 bytes - 8 byte discriminator
}
The attribute automatically implements required traits:
  • Copy - Allows bitwise copying
  • Clone - Enables cloning
  • bytemuck::Zeroable - Allows creation from zeroed bytes
  • bytemuck::Pod - “Plain Old Data” marker
  • #[repr(C)] - C-compatible memory layout

3. Use AccountLoader

Replace Account<'info, T> with AccountLoader<'info, T>:
#[derive(Accounts)]
pub struct ProcessData<'info> {
    #[account(mut)]
    pub data_account: AccountLoader<'info, Data>,
}

Account Operations

Initialize (Small Accounts ≤ 10240 bytes)

For accounts up to 10,240 bytes, use the init constraint:
#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = payer,
        space = 8 + 10232,  // 8 bytes discriminator + data
    )]
    pub data_account: AccountLoader<'info, Data>,
    #[account(mut)]
    pub payer: Signer<'info>,
    pub system_program: Program<'info, System>,
}

pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    let data = &mut ctx.accounts.data_account.load_init()?;
    data.data = [1; 10232];
    Ok(())
}
The init constraint is limited to 10,240 bytes due to CPI limitations when calling the System Program.

Initialize (Large Accounts > 10240 bytes)

For accounts larger than 10,240 bytes, use the zero constraint and create the account separately: Program:
#[account(zero_copy)]
pub struct LargeData {
    pub data: [u8; 10_485_752],  // 10MB - 8 bytes discriminator
}

#[derive(Accounts)]
pub struct InitializeLarge<'info> {
    #[account(zero)]  // Verifies discriminator not set
    pub data_account: AccountLoader<'info, LargeData>,
}

pub fn initialize_large(ctx: Context<InitializeLarge>) -> Result<()> {
    let data = &mut ctx.accounts.data_account.load_init()?;
    data.data = [1; 10_485_752];
    Ok(())
}
Client (create account first):
import * as anchor from "@anchor-lang/core";

const dataAccount = anchor.web3.Keypair.generate();
const space = 10_485_760;  // 10MB max account size
const lamports = await program.provider.connection
  .getMinimumBalanceForRentExemption(space);

// Create account via System Program
const createAccountIx = anchor.web3.SystemProgram.createAccount({
  fromPubkey: program.provider.publicKey,
  newAccountPubkey: dataAccount.publicKey,
  space,
  lamports,
  programId: program.programId,
});

// Initialize account in your program
const initializeIx = await program.methods
  .initializeLarge()
  .accounts({ dataAccount: dataAccount.publicKey })
  .instruction();

// Send both instructions
const tx = new anchor.web3.Transaction()
  .add(createAccountIx)
  .add(initializeIx);

await program.provider.sendAndConfirm(tx, [dataAccount]);

Read Data

Use load() for read-only access:
#[derive(Accounts)]
pub struct ReadData<'info> {
    pub data_account: AccountLoader<'info, Data>,
}

pub fn read_data(ctx: Context<ReadData>) -> Result<()> {
    let data = ctx.accounts.data_account.load()?;
    msg!("First 10 bytes: {:?}", &data.data[..10]);
    Ok(())
}

Update Data

Use load_mut() for mutable access:
#[derive(Accounts)]
pub struct UpdateData<'info> {
    #[account(mut)]
    pub data_account: AccountLoader<'info, Data>,
}

pub fn update_data(ctx: Context<UpdateData>) -> Result<()> {
    let data = &mut ctx.accounts.data_account.load_mut()?;
    data.data = [2; 10232];
    Ok(())
}

Advanced Patterns

Nested Zero-Copy Types

Define reusable zero-copy types with #[zero_copy] (without account):
#[account(zero_copy)]
pub struct OrderBook {
    pub market: Pubkey,
    pub bids: [Order; 1000],
    pub asks: [Order; 1000],
}

#[zero_copy]
pub struct Order {
    pub trader: Pubkey,
    pub price: u64,
    pub quantity: u64,
}

Accessor Methods for Byte Arrays

Since zero-copy uses #[repr(packed)], field references are unsafe. Use #[accessor] for safe getters/setters:
#[account(zero_copy)]
pub struct Config {
    pub authority: Pubkey,
    #[accessor(Pubkey)]  // Generate safe accessor methods
    pub secondary_authority: [u8; 32],
}

// Usage:
pub fn use_accessors(ctx: Context<UseConfig>) -> Result<()> {
    let config = &mut ctx.accounts.config.load_mut()?;
    
    // Safe getter
    let secondary = config.get_secondary_authority();
    msg!("Secondary authority: {}", secondary);
    
    // Safe setter
    let new_authority = Pubkey::new_unique();
    config.set_secondary_authority(&new_authority);
    
    Ok(())
}

Zero-Copy with PDAs

Zero-copy accounts work seamlessly with PDAs:
#[derive(Accounts)]
pub struct InitializePDA<'info> {
    #[account(
        init,
        payer = authority,
        space = 8 + std::mem::size_of::<Data>(),
        seeds = [b"data", authority.key().as_ref()],
        bump,
    )]
    pub data_account: AccountLoader<'info, Data>,
    #[account(mut)]
    pub authority: Signer<'info>,
    pub system_program: Program<'info, System>,
}

Separate Types for RPC Parameters

Zero-copy types cannot derive AnchorSerialize/AnchorDeserialize. Use separate types for instruction parameters:
// Zero-copy account
#[zero_copy]
pub struct Event {
    pub from: Pubkey,
    pub data: u64,
}

// RPC parameter type
#[derive(AnchorSerialize, AnchorDeserialize)]
pub struct EventParams {
    pub from: Pubkey,
    pub data: u64,
}

impl From<EventParams> for Event {
    fn from(params: EventParams) -> Self {
        Event {
            from: params.from,
            data: params.data,
        }
    }
}

pub fn create_event(
    ctx: Context<CreateEvent>,
    params: EventParams,
) -> Result<()> {
    let event = &mut ctx.accounts.event.load_init()?;
    *event = params.into();
    Ok(())
}

Common Pitfalls

1. Forgetting the Account Discriminator

Always add 8 bytes for the account discriminator:
//    Wrong - missing discriminator
space = std::mem::size_of::<Data>()

//    Correct - includes discriminator
space = 8 + std::mem::size_of::<Data>()

2. Using Dynamic Types

Zero-copy requires all fields to be Copy types:
//    Invalid - Vec is not Copy
#[account(zero_copy)]
pub struct InvalidData {
    pub items: Vec<u64>,  // Error!
    pub name: String,     // Error!
}

//    Valid - Fixed-size arrays
#[account(zero_copy)]
pub struct ValidData {
    pub items: [u64; 100],
    pub name: [u8; 32],
}

3. Using load_init vs load_mut

Use load_init() only for first-time initialization:
// First initialization - sets discriminator
pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
    let data = &mut ctx.accounts.data_account.load_init()?;
    data.data = [1; 10232];
    Ok(())
}

// Subsequent updates
pub fn update(ctx: Context<Update>) -> Result<()> {
    let data = &mut ctx.accounts.data_account.load_mut()?;
    data.data = [2; 10232];
    Ok(())
}

4. Not Validating Array Indices

Always validate array indices to prevent panics:
pub fn update_item(
    ctx: Context<Update>,
    index: u32,
    value: u64,
) -> Result<()> {
    let data = &mut ctx.accounts.data_account.load_mut()?;
    
    require!(
        (index as usize) < data.items.len(),
        ErrorCode::IndexOutOfBounds
    );
    
    data.items[index as usize] = value;
    Ok(())
}

Real-World Use Cases

Event Queue Pattern

Store large sequences of events efficiently:
#[account(zero_copy)]
pub struct EventQueue {
    pub head: u64,
    pub count: u64,
    pub events: [Event; 10000],
}

#[zero_copy]
pub struct Event {
    pub timestamp: i64,
    pub user: Pubkey,
    pub event_type: u8,
    pub data: [u8; 32],
}
Used by: Trading protocols, audit logs, messaging systems

Order Book Pattern

Efficient storage for trading pairs:
#[account(zero_copy)]
pub struct OrderBook {
    pub market: Pubkey,
    pub bid_count: u32,
    pub ask_count: u32,
    pub bids: [Order; 1000],
    pub asks: [Order; 1000],
}

#[zero_copy]
pub struct Order {
    pub trader: Pubkey,
    pub price: u64,
    pub size: u64,
    pub timestamp: i64,
}
Used by: DEXs (Serum, Mango), NFT marketplaces

Ring Buffer Pattern

Circular buffer for fixed-size history:
#[account(zero_copy)]
pub struct RingBuffer {
    pub head: u64,
    pub tail: u64,
    pub capacity: u64,
    pub items: [Item; 1000],
}

pub fn push_item(
    ctx: Context<PushItem>,
    item: Item,
) -> Result<()> {
    let buffer = &mut ctx.accounts.buffer.load_mut()?;
    
    let index = buffer.head % buffer.capacity;
    buffer.items[index as usize] = item;
    buffer.head = buffer.head.wrapping_add(1);
    
    Ok(())
}

Best Practices

Use std::mem::size_of to calculate exact sizes:
space = 8 + std::mem::size_of::<Data>()
Comment size calculations for clarity:
#[account(zero_copy)]
pub struct Data {
    // Total: 10240 bytes
    pub header: [u8; 32],      // 32 bytes
    pub items: [Item; 100],    // 100 * 100 = 10000 bytes
    pub footer: [u8; 208],     // 208 bytes
}  // Total: 32 + 10000 + 208 = 10240
Make code more readable:
type OrderArray = [Order; 1000];

#[account(zero_copy)]
pub struct OrderBook {
    pub bids: OrderArray,
    pub asks: OrderArray,
}
Test with large accounts to verify compute budget:
#[test]
fn test_large_account() {
    // Test with maximum data size
    let data = Data {
        data: [255; 10_485_752],
    };
    // Verify operations succeed
}

Resources

Build docs developers (and LLMs) love