What problem do PDAs solve?
One of the first things that confused me when learning Solana was where programs actually store data.
Unlike a traditional backend, Solana programs are stateless. They don't keep variables in memory between requests. Every piece of persistent data has to live inside an account.
That immediately creates a problem.
If my counter program needs to remember a counter for every user, how does it know where to find that user's data tomorrow?
Program Derived Addresses (PDAs) solve exactly that problem. They let a program deterministically derive the same account address every time from known inputs without storing the address anywhere beforehand.
Over the past week (Days 64–68 of the 100 Days of Solana challenge), I built a counter program that evolved from a simple PDA into a complete application with:
- one counter PDA per wallet
- a global config PDA
- account constraints
- pause functionality
- rent recovery through account closing
- experiments showing how seed choices affect security
Here is the mental model that finally made PDAs click for me.
The mental model
Coming from Web2, the closest analogy is a deterministic database key.
Imagine a users table where every counter row is identified by:
("counter", user_id)
Instead of generating a random UUID, you always compute the primary key from known values.
A PDA works similarly.
Rather than storing an address somewhere, Solana computes it from:
- one or more seeds
- the program ID
- a bump value
That means the program can always derive the same address later.
Unlike a database row, though, a PDA is not automatically created.
You can derive a PDA today that has no account living at that address.
The account only exists after your program initializes it.
Anatomy of a derivation
This is the exact account constraint 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>,
Every line matters.
init
Create the account if it doesn't already exist.
payer = user
The wallet pays the rent-exempt storage deposit.
space
Anchor reserves enough bytes for the account.
The extra 8 bytes are Anchor's discriminator.
seeds
[b"counter", user.key().as_ref()]
This is the logical identity of the account.
The first seed is static.
The second seed is dynamic.
Together they uniquely identify one counter for one wallet.
program ID
The program ID is automatically included in the derivation.
That means another Solana program using identical seeds produces an entirely different PDA.
This was one of the biggest "aha" moments for me.
The program ID is part of the address.
What is the bump?
When Solana derives a PDA, it repeatedly hashes:
seeds
+
program ID
+
candidate bump
starting from 255 downward.
It keeps trying until the resulting public key lands off the Ed25519 curve.
That final successful byte is called the canonical bump.
During Day 64 I used:
PublicKey.findProgramAddressSync(...)
which returned both:
[pda, bump]
Anchor stores that bump for me automatically:
counter.bump = ctx.bumps.counter;
Later instructions reuse it:
bump = counter.bump
Why the seeds matter
The seed design determines your entire data model.
During Day 68 I experimented with different derivations.
This script:
PublicKey.findProgramAddressSync(
[Buffer.from("counter"), walletA.toBuffer()],
program.programId
)
produced a completely different PDA than:
PublicKey.findProgramAddressSync(
[Buffer.from("counter"), walletB.publicKey.toBuffer()],
program.programId
)
Every wallet got its own counter.
Exactly what I wanted.
Then I removed the wallet from the seeds.
[Buffer.from("counter")]
Both wallets derived the exact same PDA.
That would create a single global counter.
Sometimes that's perfect.
For example, my config account intentionally uses:
seeds = [b"config"]
because the whole program only needs one configuration.
But if my counter used the same seed scheme, every user would be sharing one counter account.
The first initialization would succeed.
Everyone else would receive:
account already in use
That experiment made the importance of seed selection much clearer than reading documentation alone.
What the bump buys you
The bump is not random.
It is the canonical value that successfully generated an off-curve PDA.
Anchor automatically computes it when you write:
bump
and later validates it with:
bump = counter.bump
Since the bump never changes, storing it inside the account is effectively free.
Reusing the stored bump also avoids recalculating it every instruction.
My counter stores it during initialization:
counter.bump = ctx.bumps.counter;
and every later instruction validates the same PDA using that stored value.
The full lifecycle of a PDA
One thing I appreciated about this week is that I saw the complete lifecycle.
Day 64
Learn how to derive a PDA.
findProgramAddressSync()
Day 65
Actually create the account.
init
One wallet.
One counter PDA.
Day 66
Introduce a second PDA.
My program now had:
- Config PDA
- Counter PDA
I also added constraints like:
has_one = user
and
constraint = !config.paused
so invalid transactions were rejected before my handler even ran.
Day 67
Delete the account.
close = user
The close instruction looked almost empty:
pub fn close_counter(
_ctx: Context<CloseCounter>
) -> Result<()> {
Ok(())
}
Anchor handled everything else.
When the instruction completed:
- account data was cleared
- lamports were refunded
- the runtime removed the account
This isn't the same as deleting a database row.
The runtime drains the lamports, zeroes the data, and garbage-collects the account afterward.
Day 68
Break things on purpose.
I wrote a script that tried to spoof another user's PDA.
await program.methods
.increment()
.accounts({
counter: pdaB,
user: walletA,
})
.rpc();
Anchor rejected it immediately.
Why?
Because the account constraint re-derived the PDA from Wallet A's public key and discovered that I had passed Wallet B's PDA instead.
No manual ownership check.
No custom security code.
The constraint itself enforced the rule.
That was probably the biggest takeaway of the week.
What I would tell past me
The program ID is part of every PDA derivation. The same seeds in another program produce a different address.
A PDA is only an address. It does not become an account until your program initializes it.
Seed design is your data model. Choose them carefully.
Store the bump once and reuse it instead of deriving it every instruction.
init_if_neededis convenient, but I now understand why people call it a footgun. Use it intentionally.Account constraints are much more than syntax—they are a major part of your program's security.
Final thoughts
This week completely changed how I think about state on Solana.
At first, PDAs felt like magic.
Looking back, the biggest lesson wasn't how to derive a PDA it was learning that your seed design is your data model.
In Web2, you spend time designing database schemas, primary keys, and relationships because they determine how your application behaves.
On Solana, you spend that same effort designing your PDA seeds.
The difference between:
["counter"]
and
["counter", user]
is the difference between one shared counter for the entire application and one counter for every user.
That isn't just an implementation detail it defines the structure of your application.
Once I started thinking of PDA seeds the same way I think about database schema design, the rest of Solana's account model became much easier to understand.
For Web2 developers, that mental model is what finally made everything click for me.
If you're learning Solana, I highly recommend reading the official Solana PDA documentation, the Anchor PDA guide, and the Anchor crate documentation alongside building your own small project. The combination of reading and experimenting is what made these concepts stick for me.
You can also find the full source code for my counter program on my GitHub repository.
Top comments (0)