DEV Community

Prasiddh Naik
Prasiddh Naik

Posted on

What I learned about PDAs in a week of building on Solana

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>,
Enter fullscreen mode Exit fullscreen mode

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>,
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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>,
Enter fullscreen mode Exit fullscreen mode

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_needed is convenient and also a footgun. Reach for it deliberately when you genuinely want "create if missing, otherwise use existing." Default to explicit init so you never accidentally overwrite or underpay for an account you thought was new.

  • Start the local validator before you deploy. Every fetch failed and failed to get recent blockhash error I hit was because nothing was listening on localhost:8899. Terminal 1: solana-test-validator. Terminal 2: everything else.

  • The seeds constraint and has_one do different jobs. Seeds prove you passed the right account address. has_one proves 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)