DEV Community

Tobi Ayinmiro
Tobi Ayinmiro

Posted on

What I Learned About PDAs in a Week of Building on Solana

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

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

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

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

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

which returned both:

[pda, bump]
Enter fullscreen mode Exit fullscreen mode

Anchor stores that bump for me automatically:

counter.bump = ctx.bumps.counter;
Enter fullscreen mode Exit fullscreen mode

Later instructions reuse it:

bump = counter.bump
Enter fullscreen mode Exit fullscreen mode

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

produced a completely different PDA than:

PublicKey.findProgramAddressSync(
    [Buffer.from("counter"), walletB.publicKey.toBuffer()],
    program.programId
)
Enter fullscreen mode Exit fullscreen mode

Every wallet got its own counter.

Exactly what I wanted.

Then I removed the wallet from the seeds.

[Buffer.from("counter")]
Enter fullscreen mode Exit fullscreen mode

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

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

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

and later validates it with:

bump = counter.bump
Enter fullscreen mode Exit fullscreen mode

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

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

Day 65

Actually create the account.

init
Enter fullscreen mode Exit fullscreen mode

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

and

constraint = !config.paused
Enter fullscreen mode Exit fullscreen mode

so invalid transactions were rejected before my handler even ran.

Day 67

Delete the account.

close = user
Enter fullscreen mode Exit fullscreen mode

The close instruction looked almost empty:

pub fn close_counter(
    _ctx: Context<CloseCounter>
) -> Result<()> {
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

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

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_needed is 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"]
Enter fullscreen mode Exit fullscreen mode

and

["counter", user]
Enter fullscreen mode Exit fullscreen mode

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)