If you come from a Web2 background, one of the hardest things to understand about Solana is that programs are stateless. In a typical backend application, you might keep user information in a database row, a session store, Redis, or some other persistent layer. A Solana program cannot simply hold that data in memory between transactions.
That is the problem Program Derived Addresses (PDAs) solve.
A PDA is a deterministic account address derived from:
- one or more seeds,
- your program ID, and
- a small extra byte called the bump.
The PDA itself is not the state. The state lives in an account stored on-chain at that PDA.
The mental model
The closest Web2 analogy I found is this:
A PDA is like a database primary key that can be computed from the logical identity of the record.
For example, if you were building a SaaS app, a user profile row might be identified by:
("profile", user_id)
On Solana, the equivalent idea is:
[b"profile", user.key().as_ref()]
The important difference is that there is no central database table managing these keys. The address is derived on demand from the seeds and the program ID, and an account may or may not exist there yet.
Anatomy of a PDA derivation
Here is the 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>,
Let’s break this down.
b"counter"
A static seed prefix. This namespaces the PDA so it does not collide with other PDAs in the same program.
user.key().as_ref()
A dynamic seed based on the wallet interacting with the program.
seeds = [...]
Anchor hashes the seeds together with the program ID to derive the PDA.
bump
The runtime searches for a one-byte value that produces an address outside the Ed25519 curve. Because the resulting address has no corresponding private key, only the program can sign for it using the same seeds.
That last point was the biggest mental shift for me: PDAs are intentionally un-signable by wallets.
Why the seeds matter
These two derivations look similar but behave very differently.
Per-user PDA
seeds = [b"counter", user.key().as_ref()]
Every wallet gets a different counter account.
Global PDA
seeds = [b"counter"]
Every wallet resolves to the same PDA.
That second pattern is useful for things like:
- protocol configuration,
- treasury metadata,
- global admin settings.
It would be a terrible choice for a per-user counter because all users would be mutating the same account.
Authorization through constraints
This is the close instruction from my program:
#[derive(Accounts)]
pub struct CloseCounter<'info> {
#[account(
mut,
close = user,
seeds = [b"counter", user.key().as_ref()],
bump = counter.bump,
has_one = user,
)]
pub counter: Account<'info, Counter>,
#[account(mut)]
pub user: Signer<'info>,
}
What surprised me is that most of the authorization happens before my handler runs.
Anchor automatically checks that:
- the PDA was derived from the expected seeds,
- the bump matches the stored bump,
- the
counter.userfield equals the provided signer.
If any of those checks fail, the transaction is rejected before the business logic executes.
That feels much closer to declarative authorization than the manual guard clauses I usually write in backend APIs.
What the bump buys you
A common misconception is that the bump is some kind of random nonce.
It is not.
find_program_address tries bump values from 255 down to 0 until it finds a derivation that is not on the Ed25519 curve. The first valid value is called the canonical bump.
In practice:
pub bump: u8,
is often stored inside the account so later instructions can reuse it:
bump = counter.bump
This avoids re-deriving the PDA every time and guarantees you are using the same canonical derivation that was used during initialization.
The full lifecycle
The pattern I ended up using throughout the week was:
1. Derive
[b"counter", user.key().as_ref()]
2. Initialize
#[account(init, payer = user)]
This creates the on-chain account and allocates rent-exempt storage.
3. Mutate
Subsequent instructions load the PDA account and update its fields.
4. Close
close = user
Closing does not mean “delete a row from a database.”
It means:
- transfer the account’s lamports (Solana’s smallest unit) to another account,
- zero out the data,
- mark the account for cleanup at the end of the transaction.
That distinction matters because account storage on Solana is fundamentally tied to lamports funding the account’s existence.
What I would tell past me
A few things I wish I understood on Day 64:
- The program ID is part of the derivation. The same seeds in a different program produce a completely different PDA.
- PDAs cannot sign transactions by themselves. Programs sign on their behalf using the same seeds and bump.
- Store the bump in the account. It makes later instructions simpler and more reliable.
The takeaway
The biggest insight from this week is that PDAs are not a storage mechanism. They are a deterministic addressing scheme for Solana accounts.
Once I stopped thinking of them as “special accounts” and started thinking of them as program-owned, reproducible addresses derived from business logic, the entire Anchor model became much easier to reason about:
logical identity
↓
seeds + program ID
↓
PDA
↓
on-chain account stored at that address
That is the mental model I took away from working with PDAs this week.
Top comments (0)