Skip to content

Zero-copy account deserialization by default #4390

@nutafrost

Description

@nutafrost

Anchor V2 should use zero-copy (in-place) deserialization as the default account access pattern, replacing Borsh deserialize + heap copy. Borsh-style copying should remain available as an explicit opt-in for backwards compatibility.

Today Anchor has two worlds:

Account<T> (default) AccountLoader<T> (opt-in)
Deserialization Full Borsh copy onto heap Zero-copy via bytemuck
Ergonomics .field access directly .load() / .load_mut() required
CU cost High — allocates + copies Low — reads from input buffer
Alignment No restrictions Requires #[repr(C)], Pod, padding

The default path (Account<T>) is the slow path. Every account is fully deserialized on entry and re-serialized on exit, even if the instruction only reads one field. This costs significant CUs and stack space — the #1 performance complaint from Anchor developers.

As #3742 discussion feedback put it: "the default serialization should probably behave more like zero-copy but with better UX."

Problem with today's zero-copy

The current AccountLoader + #[account(zero_copy)] approach works, but the UX is painful:

  1. Byte alignment burden. Users must manually ensure #[repr(C)] and Pod compliance — no bool, no enum, no String, manual padding bytes.
  2. Two codegen paths. #[account] and #[account(zero_copy)] produce completely different code, making it hard to switch between them.
  3. Ref-counting overhead. AccountLoader uses RefCell internally, adding runtime borrow-checking that Pinocchio eliminates.
  4. IDL divergence. The IDL must encode serialization: "bytemuck" separately, and not all client generators handle it.

Proposed design for V2

Make zero-copy the default, with ergonomic access and no alignment footguns.

  1. Unified Account<T> (Collapse all account wrappers into Account<T> #4273) uses zero-copy access by default. The serializer is determined by the #[account] attribute or the plugin API (Design the custom de/serializer plugin API #4276), not by which wrapper type you pick.

  2. Automatic padding and alignment. The #[account] derive macro should insert padding fields automatically to satisfy alignment requirements, similar to how C compilers handle struct layout. Users write natural Rust structs; the macro handles repr(C) and padding.

  3. Deref ergonomics. Account<T> should implement Deref<Target = T> so field access is .field — no .load() ceremony. Mutable access via DerefMut or an explicit .as_mut() that the framework can track for dirty-checking on exit.

  4. Borsh as opt-in. For types that genuinely need dynamic-length serialization (Vec<T>, String, Option<T>), users annotate with #[account(serialization = "borsh")] to opt into copy-based deserialization. This is the escape hatch, not the default.

  5. Lazy deserialization. For instructions that don't touch every account, the framework should support lazy field access — only read the bytes you touch. This pairs naturally with the Pinocchio entry point (Migrate from Solana program model to Pinocchio runtime #4068).

Backwards compatibility

  • Programs compiled with Anchor V1 that use Account<T> + Borsh continue to work unchanged — their on-chain data format doesn't change.
  • V2 programs using the new zero-copy default produce a different on-chain layout (aligned, repr(C)). The IDL captures this via the serialization and repr fields.
  • The anchor::legacy module (Provide anchor::legacy module for V1 account types #4277) re-exports V1-style Borsh Account<T> for migration.
  • A migration guide should cover the layout differences and how to handle live account upgrades (see Migration<From, To> type from PR Add Migration<'info, From, To> account type #4060).

Related issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions