Example: Adding Optional Field
This example shows how to safely add a new field to your schema without breaking existing on-chain accounts.
Scenario: Add a nickname field to PlayerAccount without requiring data migration.
Initial Schema (v1.0.0)
Section titled “Initial Schema (v1.0.0)”File: player_v1.lumos
#[solana]#[account]#[version("1.0.0")]struct PlayerAccount { wallet: PublicKey, level: u16, experience: u64,}Generated Rust:
use anchor_lang::prelude::*;
#[account]pub struct PlayerAccount { pub wallet: Pubkey, pub level: u16, pub experience: u64,}
// Account size: 8 (discriminator) + 32 + 2 + 8 = 50 bytesGenerated TypeScript:
import { PublicKey } from '@solana/web3.js';import * as borsh from '@coral-xyz/borsh';
export interface PlayerAccount { wallet: PublicKey; level: number; experience: number;}
export const PlayerAccountBorshSchema = borsh.struct([ borsh.publicKey('wallet'), borsh.u16('level'), borsh.u64('experience'),]);Updated Schema (v1.1.0)
Section titled “Updated Schema (v1.1.0)”File: player_v1.1.lumos
#[solana]#[account]#[version("1.1.0")]struct PlayerAccount { wallet: PublicKey, level: u16, experience: u64, nickname: Option<String>, // ✅ New optional field at end}Why This is Safe:
- ✅ Old accounts deserialize correctly - Missing field defaults to
None - ✅ No byte layout change for existing fields
- ✅ Backward compatible - v1.0.0 programs can read v1.1.0 data (just ignore nickname)
- ✅ No migration required - Existing accounts work immediately
Verify Safety with lumos diff
Section titled “Verify Safety with lumos diff”lumos diff player_v1.lumos player_v1.1.lumos
# Output:# Non-Breaking Changes:# + PlayerAccount.nickname: Option<String> (new field)## Recommendation: Increment MINOR version (1.0.0 → 1.1.0)# Migration Required: NoGenerated Code Changes
Section titled “Generated Code Changes”New Rust:
#[account]pub struct PlayerAccount { pub wallet: Pubkey, pub level: u16, pub experience: u64, pub nickname: Option<String>, // New field}
// New size: 8 + 32 + 2 + 8 + (1 + 4 + N) bytes// - 1 byte: Option discriminant (0 = None, 1 = Some)// - 4 bytes: String length prefix// - N bytes: String data (variable)New TypeScript:
export interface PlayerAccount { wallet: PublicKey; level: number; experience: number; nickname: string | undefined; // New field}
export const PlayerAccountBorshSchema = borsh.struct([ borsh.publicKey('wallet'), borsh.u16('level'), borsh.u64('experience'), borsh.option(borsh.string(), 'nickname'), // New field]);Deserialization Behavior
Section titled “Deserialization Behavior”Old Account (v1.0.0) Read by New Program (v1.1.0)
Section titled “Old Account (v1.0.0) Read by New Program (v1.1.0)”// Account data (50 bytes): [wallet][level][experience]// No nickname field present
let account = PlayerAccount::try_from_slice(&data)?;// ✅ Succeeds!
assert_eq!(account.nickname, None); // Defaults to NoneHow Borsh Handles This:
Option<T>is encoded as:1 byte discriminant + T data (if Some)- If bytes don’t exist, Borsh deserializes as
None - No error, clean default behavior
Program Logic Updates
Section titled “Program Logic Updates”Creating New Accounts (v1.1.0)
Section titled “Creating New Accounts (v1.1.0)”pub fn create_player( ctx: Context<CreatePlayer>, nickname: Option<String>) -> Result<()> { let player = &mut ctx.accounts.player;
player.wallet = ctx.accounts.signer.key(); player.level = 1; player.experience = 0; player.nickname = nickname; // ✅ Can be None or Some
Ok(())}Reading Existing Accounts
Section titled “Reading Existing Accounts”pub fn get_display_name(player: &PlayerAccount) -> String { player.nickname.clone().unwrap_or_else(|| { // Fallback for old accounts without nickname format!("Player {}", &player.wallet.to_string()[..8]) })}Updating Nickname
Section titled “Updating Nickname”pub fn set_nickname( ctx: Context<SetNickname>, nickname: String) -> Result<()> { let player = &mut ctx.accounts.player;
// Works for both old and new accounts player.nickname = Some(nickname);
Ok(())}TypeScript SDK Updates
Section titled “TypeScript SDK Updates”Fetching Accounts
Section titled “Fetching Accounts”// Old accounts (v1.0.0)const player = await program.account.playerAccount.fetch(playerPubkey);console.log(player.nickname); // undefined (old account)
// New accounts (v1.1.0)const newPlayer = await program.account.playerAccount.fetch(newPlayerPubkey);console.log(newPlayer.nickname); // "CryptoKnight" (new account)Display Logic
Section titled “Display Logic”function getDisplayName(player: PlayerAccount): string { return player.nickname ?? `Player ${player.wallet.toBase58().slice(0, 8)}`;}Account Reallocation (If Needed)
Section titled “Account Reallocation (If Needed)”If you want to add data to existing accounts (e.g., set nickname for old accounts):
use anchor_lang::prelude::*;
#[derive(Accounts)]pub struct SetNicknameWithRealloc<'info> { #[account( mut, realloc = 8 + 32 + 2 + 8 + 1 + 4 + 20, // Max nickname: 20 chars realloc::payer = payer, realloc::zero = false, )] pub player: Account<'info, PlayerAccount>,
#[account(mut)] pub payer: Signer<'info>,
pub system_program: Program<'info, System>,}
pub fn set_nickname_with_realloc( ctx: Context<SetNicknameWithRealloc>, nickname: String,) -> Result<()> { require!(nickname.len() <= 20, ErrorCode::NicknameTooLong);
let player = &mut ctx.accounts.player; player.nickname = Some(nickname);
Ok(())}Rent Cost for Realloc:
// Calculate rent for 20-character nicknameconst additionalBytes = 1 + 4 + 20; // Option + length + dataconst rentPerByte = await connection.getMinimumBalanceForRentExemption(1) - await connection.getMinimumBalanceForRentExemption(0);const totalRent = rentPerByte * additionalBytes;console.log(`Rent cost: ${totalRent / LAMPORTS_PER_SOL} SOL`);Testing
Section titled “Testing”Backward Compatibility Test
Section titled “Backward Compatibility Test”#[test]fn test_old_account_deserializes_with_new_schema() { use borsh::{BorshSerialize, BorshDeserialize}; use solana_program::pubkey::Pubkey;
// Simulate v1.0.0 account (no nickname) #[derive(BorshSerialize, BorshDeserialize)] struct PlayerAccountV1 { wallet: Pubkey, level: u16, experience: u64, }
let v1_account = PlayerAccountV1 { wallet: Pubkey::new_unique(), level: 10, experience: 500, };
let bytes = borsh::to_vec(&v1_account).unwrap();
// Deserialize with v1.1.0 schema let v1_1_account = PlayerAccount::try_from_slice(&bytes).unwrap();
assert_eq!(v1_1_account.wallet, v1_account.wallet); assert_eq!(v1_1_account.level, 10); assert_eq!(v1_1_account.experience, 500); assert_eq!(v1_1_account.nickname, None); // ✅ Defaults to None}Deployment Checklist
Section titled “Deployment Checklist”- Version incremented (1.0.0 → 1.1.0)
- Optional field added at end (not in middle)
-
lumos diffshows non-breaking change - Backward compatibility tested
- Program logic handles
Nonecase - TypeScript SDK regenerated
- Docs updated with new field
- Deploy to devnet for final testing
- Deploy to mainnet
Key Takeaways
Section titled “Key Takeaways”✅ Always append optional fields - Never insert in middle
✅ Use Option<T> for new fields - Allows backward compatibility
✅ Test old accounts deserialize - Write compatibility tests
✅ Handle None gracefully - Provide sensible defaults
✅ This is a MINOR version bump - Not breaking
Next Steps
Section titled “Next Steps”- 📖 Schema Versioning Guide - Full versioning rules
- 📖 Changing Field Type Example - Breaking change
- 📖 Deprecating Field Example - Phased migration
This pattern is production-ready and safe for mainnet deployments. 🚀