DEV Community

Cover image for Understanding Solana PDAs: A Web2 Developer's Guide
Mubarak Yakubu
Mubarak Yakubu

Posted on

Understanding Solana PDAs: A Web2 Developer's Guide

On Solana, programs are stateless. If your program needs to remember something per user, per game, or per configuration, it needs a deterministic address it can find again later without storing it anywhere. PDAs are that address.

What a PDA actually is

In Web2, a database primary key is computed from a row's logical identity — user_id, order_id, something the system already knows. You never look up a row by a random UUID you can't derive. PDAs work the same way, except the database is the entire Solana account model, the key derivation is a hash, and the program ID is baked in so only your program can sign for it.

Unlike a primary key, a PDA is not stored in a table. It's derived on demand and may or may not have an account at that address yet. The program doesn't know if the account exists until it tries to read it. That's why init and init_if_needed exist.

Anatomy of a derivation

Here's the canonical pattern from my counter program:

#[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

The seeds array is the core of PDA derivation. It contains two parts: a static seed prefix b"counter" and a dynamic seed user.key().as_ref(). The static seed creates a namespace — all counters share this prefix. The dynamic seed separates one user's counter from another's.

The bump is the byte that makes the address off-curve. Anchor computes it during derivation and stores it on the account. The init constraint tells Anchor to create the account at the derived address. payer = user says the user pays the rent. space reserves enough bytes for the account data.

Why the seeds matter

The seeds you choose determine who can access what. In my counter program, I used [b"counter", user.key().as_ref()]. This gives every wallet its own PDA. If I had used only [b"counter"], every wallet would get the same PDA — which is useful for a global config but disastrous for a per-user counter.

I tested this on Day 68. The per-user derivation gave different addresses for different wallets. The global derivation gave the same address for every wallet. Same program ID, same derivation function, different seeds.

Per-user counter PDAs:
  Wallet A PDA: 3ZQj5KhoHaoDb9g1LoFpDZ5tAxMwA4ZumnUzrxmMPFfg
  Wallet B PDA: DgfkXgoFoHDRUGKiHvduDHrjuofRJKoFyJ13SqWqMgFs
  Same address? false

Global counter PDA (no wallet in seeds):
  Derived from A: 2ESHd2ZG3Yjh3kUZdSLGhweaVPfLaqs1qqqF4Hgh4Ndn
  Derived from B: 2ESHd2ZG3Yjh3kUZdSLGhweaVPfLaqs1qqqF4Hgh4Ndn
  Same address? true
Enter fullscreen mode Exit fullscreen mode

What the bump buys you

The canonical bump is the first bump value that produces an off-curve address. find_program_address returns it along with the PDA. Anchor stores it for you when you write bump in the constraint.

You should re-pass the stored bump on subsequent instructions rather than re-deriving every time. Re-derivation is expensive (it hashes the seeds with 256 possible bump values). Storing the bump is free — it's just one byte on the account.

The full lifecycle

Over the course of this arc, I walked through the entire lifecycle of a PDA account:

  1. Derive the address — Use find_program_address with seeds and program ID. No account exists yet, just the address.
  2. Initialize the account — Use init in the accounts struct. This creates the account at the PDA, pays rent from the user, and stores the bump.
  3. Mutate the data — Call instructions like increment. The seeds constraint re-derives the address and verifies it matches the passed account.
  4. Close the account — Use close = user to drain lamports back to the user and mark the account as closed.

Closing is not "delete from a table." It's zero the data, transfer the lamports, and mark it for garbage collection at the end of the transaction. The runtime removes it from the next slot's account state.

What I would tell past me

A few things I wish I had known on Day 64:

  • The program ID is part of the derivation. The same seeds in a different program produce a different address. This is why PDAs are program-specific.
  • PDAs cannot sign transactions on their own. Only programs can sign on their behalf using the same seeds. The program signs for the PDA, not the other way around.
  • init_if_needed is convenient and also a footgun. Use it deliberately when you want idempotent creation, not by default. If you use it everywhere, you lose the ability to know whether an account was just created or already existed.
  • The bump is not optional. Without it, you can't re-derive the address. Store it on the account and pass it back on every instruction that uses the PDA.

Resources

PDAs are not complicated. They are deterministic addresses derived from seeds, a program ID, and a bump. They replace keypair tracking and let your program own state without a private key. The pattern is always the same: derive, initialize, mutate, close.

If you want to go deeper, start here:

My code is on GitHub.

Top comments (0)