DEV Community

Cover image for What I learned about PDAs in a week of building on Solana
Akeem Palmer
Akeem Palmer

Posted on • Originally published at akeempalmer.com

What I learned about PDAs in a week of building on Solana

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

On Solana, the equivalent idea is:

[b"profile", user.key().as_ref()]
Enter fullscreen mode Exit fullscreen mode

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

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

Every wallet gets a different counter account.

Global PDA

seeds = [b"counter"]
Enter fullscreen mode Exit fullscreen mode

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

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

is often stored inside the account so later instructions can reuse it:

bump = counter.bump
Enter fullscreen mode Exit fullscreen mode

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

2. Initialize

#[account(init, payer = user)]
Enter fullscreen mode Exit fullscreen mode

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

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

That is the mental model I took away from working with PDAs this week.

Top comments (0)