Deploying new Solana program code does not transform the accounts already onchain. If the new binary expects a different layout, old state can fail to deserialize or, worse, decode into a valid but incorrect meaning.
This guide gives you eight checks for changing program-owned account schemas. It uses Anchor's current migration and realloc controls, then adds the rollout, observability, and rollback rules a production dataset needs.
Read the program upgrade guide first for build, authority, deployment, and binary-verification controls.
Checks 1 and 2: define the schema transition
Check 1: inventory every deployed layout
Start with observed account data, not the latest Rust struct. Record each account family, discriminator, owner, allocated length, serialized layout, PDA recipe, active count, and known historical version.
A new field can affect more than bytes. It can change authorization, indexing, CPI inputs, client decoding, and close behavior. Write the transition as V1 -> V2, including defaults for every new field and treatment for malformed legacy accounts.
Keep fixed binary fixtures for each version. A fixture should contain raw account bytes, expected address, expected decoded fields, and target conversion output.
Check 2: separate schema version from program version
One program binary may need to read several account versions during a staged rollout. Give the data a version signal or use unambiguous discriminators and types.
Choose one policy:
- read V1 and V2, write V2
- migrate V1 during the next authorized mutation
- run an explicit migration instruction before enabling new features
- create a new PDA namespace and leave old accounts readable
Avoid silently treating zeroed new fields as proof of migration. A valid default value and an unmigrated value can be identical.
Checks 3 and 4: calculate space and funding
Check 3: calculate serialized size exactly
Anchor's standard account space includes its discriminator plus the serialized fields. Its space reference lists fixed widths and prefixes, including 4 bytes for a Vec or String length before the contents.
Use InitSpace and bounded #[max_len] values where they match the model:
#[account]
#[derive(InitSpace)]
pub struct PositionV2 {
pub version: u8,
pub authority: Pubkey,
pub balance: u64,
#[max_len(64)]
pub label: String,
}
pub const POSITION_V2_SPACE: usize = 8 + PositionV2::INIT_SPACE;The maximum length is a capacity contract, not input validation. Check actual string or vector length before writing.
Check 4: define realloc payer, zeroing, and shrink behavior
Anchor's realloc constraint requires the target size, a payer, and a zeroing choice:
#[account(
mut,
realloc = POSITION_V2_SPACE,
realloc::payer = payer,
realloc::zero = true,
)]
pub position: Migration<'info, PositionV1, PositionV2>,Growing an account can require more lamports to maintain its funding requirement. Decide who pays and cap the allowed increase. Shrinking changes the allocated length and may return excess lamports depending on the framework path.
Use zeroing when newly allocated bytes must not expose prior memory contents to later decoding. Regardless of the flag, initialize every new logical field explicitly.
Checks 5 and 6: convert once and remain restartable
Check 5: use a typed conversion boundary
Current Anchor documentation includes Migration<'info, From, To>. It validates that the account deserializes as the old type, then requires the new type to be serialized on instruction exit.
pub fn migrate_position(ctx: Context<MigratePosition>) -> Result<()> {
let old = &ctx.accounts.position;
ctx.accounts.position.migrate(PositionV2 {
version: 2,
authority: old.authority,
balance: old.balance,
label: String::new(),
})?;
Ok(())
}Keep authorization and PDA constraints around the migration account. Typed conversion does not decide who may migrate, which record belongs to which user, or whether the source state is economically valid.
Check 6: make the migration idempotent or explicitly terminal
Production batches get retried. A second call must either return success without changing V2 again or fail with a clear AlreadyMigrated result.
Never apply a transformation such as fee accrual, balance multiplication, or reward initialization twice. Separate pure schema conversion from business actions.
Persist enough evidence to resume safely: account address, source version, target version, transaction signature, slot, and outcome. Avoid one global cursor that can become a hot writable account; partition progress when the dataset is large.
The account-locking guide explains how shared writable state constrains parallel batches.
Checks 7 and 8: stage the rollout and prove rollback
Check 7: deploy in observable batches
Test fixtures locally, then migrate a representative devnet set. On mainnet, begin with low-risk accounts and stop when any invariant or error budget crosses its threshold.
Track:
- attempted, migrated, skipped, and failed counts
- error codes and simulation logs
- compute units and transaction size
- lamports added or returned
- source and target data lengths
- post-migration decoded invariants
RPC Edge can support reliable reads, submission, confirmation, and trace collection. It cannot correct a bad conversion or incomplete authorization rule.
Check 8: test both binary and data rollback
Deploying the previous program binary is safe only when it can read every account the new program has written. Test that path against migrated fixtures before release.
If V2 is not backward-compatible, choose a recovery method in advance: forward-fix V2, maintain a dual-reader binary, or run a tested reverse conversion. Never promise rollback when the only recoverable artifact is the old .so file.
Run the five-layer debugging trace when a batch produces account, CPI, or compute failures.
The 8-Check Account Migration Gate
Before enabling a new schema:
- inventory raw deployed layouts and keep versioned fixtures
- define how each binary reads and writes each schema version
- calculate discriminator, prefixes, fields, and maximum lengths
- define realloc size, payer, funding cap, zeroing, and shrink rules
- convert through a typed, authorized, PDA-constrained boundary
- make retries idempotent and record per-account outcomes
- stage observable batches with explicit stop thresholds
- prove old-binary compatibility or document a different recovery path
The durable artifact is a migration manifest containing source and target schemas, fixture hashes, conversion rules, funding bounds, batch ownership, stop conditions, and rollback evidence.
FAQ, sources, and next step
Is realloc the same as a migration?
No. Realloc changes account length. A migration also validates the source schema, converts its meaning, initializes new fields, and proves rollout safety.
Should every account include a version field?
Not always. Separate discriminators or PDA namespaces can encode versions. The program and clients still need an unambiguous decoding contract.
Can migration happen lazily?
Yes, when every path can safely read legacy state and the first authorized mutation performs an idempotent conversion.
Does zeroing initialize the new schema?
No. Zeroing controls newly allocated bytes. Your handler must assign every logical field and validate its invariants.
Primary sources
- Anchor account constraints
- Anchor account types and Migration
- Anchor account space
- Anchor zero-copy accounts
For a migration that crosses program upgrades, multiple PDA generations, indexers, and client releases, book a Builderz architecture review.



