Account space and layout
An account's data field is a fixed slab of bytes. When you initialize an account, you tell the runtime how many bytes you want, you pay rent for that many, and the size never changes. Every field on your Rust struct has to fit into that slab in a known, fixed amount of room. The math is simple: 8 bytes for a discriminator that says what type the account is, plus the size of each field added together. Everything that follows is variations on that one sentence.
A Solana account is a fixed-size struct
In dynamic languages, you can keep adding fields to a dictionary forever. The runtime grows the storage as you go, and you don't think about how many bytes a value takes. In typed systems languages like C and Rust, a struct has a fixed size at declaration. The compiler computes it once, allocates exactly that much memory, and won't let you grow the struct after the fact.
Solana accounts follow the second model. The data field of an account is allocated to a specific byte count when the account is created. That allocation is the account's storage forever. You can write different values into those bytes, but you cannot add a new byte that wasn't there at creation, and you cannot remove bytes you allocated. The size is part of the account's identity in a real sense, because rent was paid for that many bytes and that's how many the chain reserves for the account.
This is the most important thing to internalize about layout: you decide the size of an account at init, and you live with that decision. Programs that need accounts to grow later use realloc, which costs additional rent and has its own constraints. Programs that need to store more data than fits in one account use multiple accounts, each at its own PDA, each sized at creation. There is no implicit growth.
The diagram above lays out exactly what's inside the data field of a Vault account. Eight bytes of discriminator, 32 bytes for the authority pubkey, 8 bytes for the u64 total, 1 byte for the bump. The sum is 49 bytes. That's the value you pass to space when you initialize the account.
The 8-byte discriminator
Every Anchor account starts with 8 bytes that aren't part of your struct: a discriminator. The discriminator is a hash derived from the account type's name, stored at the beginning of the data field, and checked by Anchor before deserializing the rest. Its job is to make sure the bytes you're about to interpret as a Vault were actually written by a Vault-shaped instruction, and not by some other instruction that happens to produce 49 bytes.
Without the discriminator, a class of bugs called type confusion becomes possible. If you have two account types Vault and Pool that happen to be the same size, an attacker could initialize a Pool account, pass it to your Vault instruction, and your deserialization would succeed because the bytes parse cleanly as either struct. With different field meanings. The attacker has effectively turned a Pool into a Vault for the purposes of one call, and any check you wrote that depends on the type of the account silently fails.
The discriminator stops that. The first 8 bytes of a Vault account are the hash of "account:Vault". The first 8 bytes of a Pool account are the hash of "account:Pool". When Anchor sees Account<'info, Vault> in your struct, it reads those 8 bytes and compares them to the Vault discriminator. Mismatch means rejection. No matter what's in the rest of the data, you can be sure the account is the type you asked for.
This is also why the formula is always 8 + sum_of_fields and never just sum_of_fields. The 8 bytes are real bytes that need real allocation. Forget them and your account is 8 bytes short and Anchor's first attempt to write the discriminator overflows.
How many bytes each type takes
Computing the size of an account by hand means knowing the size of each Rust type that goes into it. The numbers are fixed and easy to memorize.
For everyday programs, the table above is enough. A Pubkey is 32 bytes. The fixed-width integers are their size in bits divided by 8. A bool is 1 byte even though it could fit in 1 bit, because Borsh serialization, the format Anchor uses, doesn't bit-pack. An Option<T> adds a 1-byte discriminator byte, similar in spirit to the account's main discriminator, that says whether the option is Some or None. A fixed-size array [T; N] is exactly N copies of T, with no overhead beyond the elements themselves.
The arithmetic for a struct is what you'd expect: sum the field sizes, add 8 for the discriminator, that's your space. For the Vault from earlier: 8 + 32 + 8 + 1 = 49.
You almost never compute this by hand. Anchor provides a #[derive(InitSpace)] macro that adds an INIT_SPACE constant to your struct, equal to the sum of the field sizes. You then write space = 8 + Vault::INIT_SPACE, and the compiler computes the right number. When you change the struct's fields, the constant updates automatically. This is the idiomatic form and what you should reach for in every program.
Variable-length fields and the bounds you must give them
The trouble starts when you reach for String or Vec<T>. These types are variable-length by nature: in normal Rust, they grow as you push to them. On Solana, that's impossible. The account's data field can't grow. So Anchor needs to know, at init time, how many bytes to reserve for each variable-length field.
This is what #[max_len(N)] does. You write it as an attribute on the field, and it tells Anchor the maximum number of elements the field will ever hold. The space allocated for the field is 4 + max_len × size_of_element, where the 4 bytes hold the current length and the rest hold the elements. The current length grows as you push, and the runtime rejects any push that would take you over the cap.
The bound is a design decision. Set it too low and your application breaks when users hit the cap. Set it too high and every account pays rent for bytes that may never be used. A 100-pubkey vote list at 32 bytes each is 3,200 bytes just for the vector, which is enough to cost the user about 0.022 SOL in rent. That cost is paid up front, once, when the account is initialized. The user pays for a 100-vote bound whether they end up using 5 votes or 100.
The way most production programs handle this is to avoid putting variable-length data on a single account when the bound would be large or unbounded. Instead, each element gets its own PDA, derived from the parent account's key and an index or sub-identifier. A proposal with thousands of votes wouldn't store them in a Vec on the proposal account. It would store each vote in a separate Vote PDA derived from (proposal, voter). The proposal account stays small and predictable, and there's no architectural cap on how many votes can be cast.
Schema migration is hard, so design carefully
Once an account is created with a given size, you cannot meaningfully change the struct it represents. You can deploy a new version of your program that interprets the existing bytes differently, but you can't grow old accounts to fit a new field, and you can't shrink them to remove one. The realloc constraint lets you change the size of a specific account in a specific instruction, paying or refunding rent as you go, but it doesn't help you migrate every existing account at once.
In practice, you get one chance to pick the layout, and you live with it. This shapes how programs are designed. Reserve a few bytes for future use if you think the struct might grow. Avoid putting derived data on the account, since recomputing it from inputs costs less than reserving space for it. Be conservative with Vec and String bounds.
If a serious migration is unavoidable, the standard pattern is to deploy a new program version with a new account type, write a migration instruction that takes an old account and a fresh new account, copies the relevant data across, and closes the old account to refund its rent. The migration runs once per account, paid for by either the user or the protocol depending on the situation. It's tedious enough that you want to design the original layout carefully to make sure you never have to do it.
What you actually do day to day
For most accounts you'll write, the workflow is short. Define your #[account] struct. Add #[derive(InitSpace)] above it so the macro computes the size for you. For any String or Vec, add #[max_len(N)] with a bound you've thought about. In your init instruction, set space = 8 + YourStruct::INIT_SPACE. The compiler does the arithmetic, the runtime allocates the bytes, and your account is the right size.
When you change the struct, the size updates automatically. When you add a new bounded vector, you set its bound, and the rest takes care of itself. The whole topic collapses to one question per field: how big does this need to be, and what happens if I'm wrong? Answer that, and the rest is mechanical.