On Solana, programs are stateless. If your program needs to remember something per user, per game, or per config, it needs a deterministic address it can find again later without storing that address anywhere. Program Derived Addresses (PDAs) are the addresses.
The mental model
The Web2 framing that finally clicked for me: PDAs are like a database primary key you can compute from a row's logical identity — except the "database" is Solana's entire account model, the key derivation is a hash, and your program ID is baked in so only your program can sign for accounts at that address.
In a normal backend, you insert a row, the database assigns an ID, and you store that ID somewhere so you can look the row up later. On Solana, there is no central database handing out IDs. You pick a logical identity — "counter" plus a user's public key, for example — hash it together with your program ID, and you get an address. Anyone who knows the seeds and the program ID can recompute the same address. No lookup table required.
Where the analogy breaks (and you should be honest about this):
- PDAs are not stored in a table. They are derived on demand. The address might exist as an on-chain account, or it might not — derivation and initialization are separate steps.
- There is no row until you pay rent. Creating an account at a PDA costs lamports (Solana's smallest currency unit; 1 SOL = 1 billion lamports). That payment is called rent, and it reserves space on the chain for your data.
- "Deleting" an account is not a SQL DELETE. Closing an account zeros its data, sends the lamports back to a wallet, and the runtime garbage-collects the account at the end of the transaction.
If you hold onto one sentence: a PDA is an address your program can predict, and optionally own, because it was derived from seeds only your program knows how to use.
Anatomy of a derivation
Here is the canonical pattern from my counter program, copied verbatim:
#[account(
init,
payer = user,
space = 8 + Counter::INIT_SPACE,
seeds = [b"counter", user.key().as_ref()],
bump
)]
pub counter: Account<'info, Counter>,
Walk through each piece:
seeds = [b"counter", user.key().as_ref()] — This is the logical identity. The first element is a static prefix: a string tag that namespaces this account type inside your program. The second is dynamic: the user's public key, so every wallet gets its own counter. These bytes are concatenated and hashed together with your program ID.
The program ID is not optional. find_program_address (and Anchor's seeds constraint) always includes the deploying program's public key in the hash. The same seed bytes in a different program produce a completely different address. That is a feature, not a quirk.
bump — PDAs must live off the ed25519 curve so they have no private key. The runtime tries bump values 255 down to 0, appending each candidate as an extra seed byte, until the hash result is not a valid curve point. The winning byte is the canonical bump. It is part of the derivation input, not a separate magic number floating beside the seeds.
init — Derivation gives you an address. init actually creates an account there and writes your struct's initial data. Until init runs, the PDA address is just math — no account exists yet.
space = 8 + Counter::INIT_SPACE — Every Anchor account starts with an 8-byte discriminator (so the runtime knows which struct type this is), plus whatever fields you defined. You pay rent proportional to this size.
payer = user — The wallet signing the transaction pays the rent to create the account.
On subsequent instructions, I do not re-derive the bump from scratch. I store it in the account and pass it back:
#[account(
mut,
seeds = [b"counter", user.key().as_ref()],
bump = counter.bump,
has_one = user,
)]
pub counter: Account<'info, Counter>,
Anchor verifies that counter.bump is the canonical bump for these seeds. If you pass the wrong bump, the transaction fails before your handler runs.
Why the seeds matter
Yesterday I ran a collision exploration script against my deployed program. The output is worth keeping because it makes the seed design concrete.
Per-user counter — seeds: ["counter", wallet_pubkey]
Per-user counter PDAs
Wallet A PDA: 8Fr8LiP6khZsTcDAeh7Gjvot5TFCbPEDBxuzv7Ju5JMj
Wallet B PDA: 6ba3KK9vCMYPogDpXrHoS8fYsVxy893vCy7kudwkM4fa
Same address? false
Alice and Bob each get their own counter. This is what you want when state belongs to one user.
Global counter — seeds: ["counter"] (no wallet)
Global counter PDA (no wallet in seeds)
Derived from A's perspective: 3VKtGLreXepsZFDnyhFKsCUz9cjy96b1cHXLaYj3XA6z
Derived from B's perspective: 3VKtGLreXepsZFDnyhFKsCUz9cjy96b1cHXLaYj3XA6z
Same address? true
Every wallet derives the same address. That is exactly what you want for a global config singleton — my program uses [b"config"] for admin settings and a pause flag. It would be a disaster for a per-user counter, because every user would be mutating the same account.
Near-miss seed variants — change one character and you get a different address entirely:
Near-miss seed variants
["counter", walletA] -> 8Fr8LiP6khZsTcDAeh7Gjvot5TFCbPEDBxuzv7Ju5JMj
["counters", walletA] -> Cre7aNxq6dHKvp68ucUgLfCRzrSNjPNDr2tzyeqMbNcn
["counter\0", walletA] -> FFWPDBuhjXGqALszpVrp9DWJcPLTd4TCoKrk2jpMDAwr
["Counter", walletA] -> 494XwXexJXLpiKk2APhairz1E7KLGp7hiQ4ogQBAQKqf
Seeds are not fuzzy. "counter" and "counters" are different namespaces. "Counter" and "counter" are different. A null byte matters. Treat seed strings like schema migrations: changing them creates a new address, not an updated row.
I also tried to spoof a PDA — pass Wallet B's counter address while signing as Wallet A:
Attempting to spoof a PDA...
Spoof rejected: AnchorError caused by account: counter. Error Code: ConstraintSeeds. Error Number: 2006. Error Message: A seeds constraint was violated.
The runtime did not care that I knew an address. It recomputed the expected PDA from the seeds and the signer, saw they did not match the account I passed in, and rejected the transaction. That is the whole point of putting seeds in the account constraint.
What the bump buys you
There are 256 possible bump values (255 down to 0). Only one is canonical — the first value that produces a valid off-curve address when tried in that order.
You should always use the canonical bump. Non-canonical bumps might derive valid-looking addresses in some edge cases, but Anchor's seeds constraint expects the canonical one. Passing a wrong bump fails verification.
When you write bump in an init constraint, Anchor finds the canonical bump for you and exposes it as ctx.bumps.counter. Store it in your account struct:
pub struct Counter {
pub user: Pubkey,
pub count: u64,
pub bump: u8,
}
On every later instruction, pass bump = counter.bump instead of re-running the 255→0 search. Re-derivation is cheap in a script and expensive inside a tight on-chain loop. Storing one byte in your account is free by comparison.
The full lifecycle
Here is the sequence my counter program went through over five days, in order:
1. Derive the address (off-chain or in a client)
const [counterPda] = PublicKey.findProgramAddressSync(
[Buffer.from("counter"), user.toBuffer()],
program.programId
);
This is pure math. No transaction, no rent, no account yet.
2. Initialize the account at that address
pub fn init_counter(ctx: Context<InitCounter>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.user = ctx.accounts.user.key();
counter.count = 0;
counter.bump = ctx.bumps.counter;
// ...
Ok(())
}
The init constraint creates the account, pays rent from the user, writes the discriminator and initial fields.
3. Mutate on subsequent instructions
pub fn increment(ctx: Context<Increment>) -> Result<()> {
let counter = &mut ctx.accounts.counter;
counter.count = counter.count.checked_add(1).ok_or(CounterError::Overflow)?;
Ok(())
}
The account already exists. Anchor verifies seeds, bump, and has_one = user before your code runs.
4. Close to reclaim rent
#[account(
mut,
close = user,
seeds = [b"counter", user.key().as_ref()],
bump = counter.bump,
has_one = user,
)]
pub counter: Account<'info, Counter>,
close = user does three things: it drains the account's lamports to the user, zeroes the data, and marks the account for deallocation at the end of the transaction. My test logged roughly 1.23M lamports refunded — real money back in the wallet, not a metaphorical row deletion.
One thing that tripped me up: closing is not instant visibility. Within the same transaction the account still exists until the runtime finishes. After confirmation, getAccountInfo returns null. Plan your client code accordingly.
What I would tell past me
The program ID is part of the derivation. Same seeds, different program, different address. Copy-pasting seed logic between programs without updating the program ID will silently point at the wrong account.
PDAs cannot sign transactions on their own. They have no private key. When a PDA needs to transfer tokens or invoke another program, your program signs on its behalf using signer seeds — the same seed bytes plus the bump, passed to
invoke_signed. Client wallets sign with a keypair; programs sign for PDAs with seeds.init_if_neededis convenient and also a footgun. Reach for it deliberately when you genuinely want "create if missing, otherwise use existing." Default to explicitinitso you never accidentally overwrite or underpay for an account you thought was new.Start the local validator before you deploy. Every
fetch failedandfailed to get recent blockhasherror I hit was because nothing was listening onlocalhost:8899. Terminal 1:solana-test-validator. Terminal 2: everything else.The seeds constraint and
has_onedo different jobs. Seeds prove you passed the right account address.has_oneproves the data inside that account belongs to the signer. You need both when users own their own PDAs.
Where to go from here
If you are exactly where I was a week ago — program compiles, tests pass, mental model still fuzzy — write your own explainer. Every sentence you cannot finish cleanly is a gap worth filling.
Top comments (0)