When I first heard about Program Derived Addresses (PDAs), they sounded like one of those Solana concepts that everyone claims are simple until you actually have to use them. After spending the past week building a counter program with Anchor, breaking things on purpose, fixing them, and watching transactions fail for reasons I didn't understand at first, I can finally say I have a mental model that makes sense.
If you're coming from a Web2 background like I am, I hope this saves you a few hours of confusion.
Why do PDAs even exist?
One thing I learned early is that Solana programs don't store state the way a traditional backend application does. If your program needs to remember something, whether it's a user's profile, a game score, a counter, or a configuration, it needs a predictable account where that data lives.
That's exactly what a PDA gives you.
Think of it like a database primary key that can always be computed again. Instead of storing an address somewhere and looking it up later, your program derives it whenever it needs it.
The difference is that this address isn't random. It's derived from your program's seeds and your program ID, meaning the same inputs will always produce the same PDA.
The mental model that finally clicked
The analogy that helped me most was this:
Imagine a users table in a database.
Instead of looking up a row using an auto-increment ID, imagine you could always calculate the row's primary key from something like:
- the application
- the user's wallet address
- a fixed prefix
That's basically what PDAs feel like.
Except there isn't actually a database.
The Solana account model is the storage layer, and PDAs are deterministic addresses that your program can always derive whenever it needs them.
Another important thing I learned is that deriving a PDA doesn't automatically create an account.
You can derive thousands of PDAs that don't exist yet. An account only comes into existence after your program initializes it.
Breaking down my own PDA
This is the pattern I used in 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>,
Here's what each piece does.
-
b"counter"is a static seed that identifies what kind of PDA this is. -
user.key().as_ref()makes the PDA unique for each wallet. -
seedsare combined with the program ID to derive the address. -
bumpis the extra byte Anchor finds to make sure the resulting address falls off the Ed25519 curve.
That last part confused me for a while.
The bump isn't magic. Anchor simply tries different bump values until it finds one that produces a valid PDA. Once it finds it, that's the canonical bump you should keep using.
Why your seeds matter
One of my biggest lessons came from intentionally changing my seeds and watching what happened.
Using:
seeds = [b"counter", user.key().as_ref()]
gave every wallet its own counter.
Changing it to:
seeds = [b"counter"]
meant everyone derived exactly the same PDA.
Neither approach is wrong.
It depends entirely on what you're building.
A global configuration account? A shared PDA is perfect.
A personal counter for every user?
A shared PDA becomes a disaster because everyone writes to the same account.
That experiment made the purpose of seeds much clearer than any documentation I had read.
What the bump actually buys you
The bump exists because not every derived address can safely become a PDA.
Anchor searches for the canonical bump value that works and stores it for you when you write:
bump
Later instructions can simply reuse the stored bump instead of recalculating it every time.
It's a small optimization, but it's also cleaner and less error-prone.
The lifecycle of a PDA
Over the past week, I noticed that every PDA in my project followed the same lifecycle.
- Derive the PDA from seeds.
- Initialize the account with
init. - Read or update its data whenever needed.
- Close the account when it's no longer useful.
Closing a PDA was another interesting lesson.
Coming from Web2, I kept thinking of it as deleting a database row.
It isn't.
Closing an account transfers its rent back to a recipient, clears the data, and marks the account for cleanup once the transaction finishes.
It's a subtle difference, but understanding it helped me appreciate how Solana manages accounts under the hood.
What I'd tell myself on Day 64
If I could go back to when I started this project, I'd probably leave myself these notes:
- Your program ID is part of every PDA derivation. The same seeds in another program produce a completely different address.
- A PDA cannot sign transactions by itself. Your program signs on its behalf using the same seeds.
- Seeds determine your data model. Think carefully before choosing them.
- Don't treat
init_if_neededas your default solution. It's useful, but it's easy to misuse if you don't fully understand when accounts should already exist. - Breaking things on purpose taught me more than reading another tutorial ever did.
Final thoughts
This week wasn't just about building a counter program.
It was about understanding how Solana thinks about state.
PDAs stopped feeling like some cryptographic trick and started feeling like a practical design tool. Once that mental model clicked, the rest of my Anchor code became much easier to reason about.
I'm still learning, but I can already tell this is one of those concepts that changes how you design Solana programs.
If you're learning PDAs too, I'd recommend reading the official Solana PDA documentation alongside the Anchor PDA guide, then building something simple and deliberately changing the seeds to see what breaks. That's the exercise that made everything click for me.

Top comments (0)