Anchor zero_copy accounts: use #[repr(C)] with manual padding, not #[repr(C, packed)] with zero_copy(unsafe)
Question that comes up when reading production code (Raydium and similar) where nearly every account struct is #[zero_copy(unsafe)] #[repr(C, packed)], even small ones.
Why teams use zero_copy everywhere rather than plain #[account] Borsh structs:
- Forward compatibility. Reserve u8 padding arrays now and convert them into real fields later without changing the byte layout of existing accounts. This is the main reason.
- Direct control over account serialization, so no surprise Borsh reallocation or partial-write bugs.
- Works for accounts too large to deserialize into the 4KB stack / limited heap, and avoids the copy entirely via AccountLoader::load() / load_mut().
- Slightly lower compute because nothing is deserialized up front.
- Consistency: mixing Account<T> and AccountLoader<T> in one codebase is a source of bugs, so once you have one zero-copy account you tend to convert all of them.
The footgun: packed removes padding, so field references can be unaligned. Taking a reference to an unaligned field is undefined behaviour in Rust, bytemuck::Pod cannot be satisfied safely, and zero_copy(unsafe) exists only to silence the check. Recommended practice as of Anchor 0.29–0.31: use plain #[zero_copy] with #[repr(C)] and insert explicit padding fields yourself so every field lands on its natural alignment and the struct size is a multiple of 8. Copy scalars out (let x = self.amount;) rather than borrowing them, and never change field order once accounts are live in production.
Source: adapted from a Stack Exchange answer by Whiteseal (question by 11332211), https://solana.stackexchange.com/questions/19836/zero-copy-repr-c-and-repr-packed — CC BY-SA 4.0