On Solana, programs are stateless. If your program needs to remember something per user, per game, per config, it needs a deterministic address it can find again later without storing that address anywhere. PDAs are that address.
I spent this week building a small Anchor counter program — nothing exotic, just enough surface area to run into the real questions: why does find_program_address need a program ID, why is the bump part of the seeds and not a separate thing bolted on afterward, and what actually happens when an account gets “closed.” This post is me writing down the answers before they fade, aimed at whoever is where I was a week ago.
The mental model
The Web2 shorthand I kept reaching for was: a PDA is like a database primary key you can compute from the row’s logical identity. If you know the user ID, you know the row. You never store the key anywhere separate from the thing that produces it.
That analogy gets you most of the way, with the database being the entire Solana account model, the key derivation being a hash, and the program ID baked into the hash so that only your program can ever sign for the result.
Where it breaks: a primary key always points at a row that exists. A PDA is derived on demand and may or may not have an account sitting at that address yet. Deriving the address and creating the account are two separate steps, and the derivation works identically whether or not anything has been initialized there. A PDA is closer to “the address this row would live at, if it exists” than to a key you look up in a table.
Anatomy of a derivation
Here’s the canonical pattern from my own program, from the InitCounter accounts struct:
rust
[account(
init,
payer = user,
space = 8 + Counter::INIT_SPACE,
seeds = [b"counter", user.key().as_ref()],
bump
)] pub counter: Account<'info, Counter>,
Walking through each piece:
seeds = [b”counter”, user.key().as_ref()] — a static label, “counter”, plus a dynamic component, the signer’s public key. Both get hashed together with the program ID.
The program ID isn’t in the seeds array, but it’s an input to the derivation anyway — find_program_address takes it as a separate argument. Same seeds, different program, different address. This is what makes the address belong to your program specifically.
bump — Solana public keys are points on the ed25519 curve. A PDA is deliberately not one of those points, because that’s what guarantees no private key exists for it. The derivation function starts at bump value 255 and counts down, hashing seeds + program ID + bump each time, until it lands on a result that is off the curve. The bump is not a separate mechanism from the seeds — it’s the extra byte you have to smuggle into the hash input to force the output off-curve. Whatever value first produces an off-curve address is the canonical bump, and that’s the one find_program_address (and the bump keyword in Anchor) hands back.
init / payer / space — this is the “create the row if it doesn’t exist yet” step. payer says who funds the account, space says how many bytes to reserve. 8 + Counter::INIT_SPACE is the byte layout: InitSpace computes the size of your struct’s fields, and the extra 8 bytes are Anchor’s discriminator — a prefix it stamps on every account so it can tell what type is stored there later. Forgetting that 8 is a fast way to corrupt an account.
Why the seeds matter
This is the part that actually determines who can write where, and it’s easy to get backwards.
I derived the same PDA two ways: [b”counter”, user.key().as_ref()] and [b”counter”] — with no wallet mixed in.
The first gives every wallet its own address. Alice’s counter and Bob’s counter land at two different places on the ledger, because their public keys are different inputs to the hash. That’s what you want for per-user state.
The second gives every caller the same address, regardless of who’s asking. That’s not automatically wrong — it’s exactly what you want for a global singleton, like a config account with a paused flag and an admin key. But if you meant to write a per-user counter and left the user out of the seeds by accident, every wallet after the first one to call init hits an already in use error, because the system program refuses to create the same address twice. Same mechanism, opposite outcome, depending entirely on what you decided to hash in.
PDAs don’t really “collide” in the cryptographic sense — engineering two different seed inputs to land on the same address is infeasible. The collisions that actually bite you are the ones you write on purpose, by leaving identity out of the seeds and not noticing until a second user shows up.
What the bump buys you
Only the canonical bump — the one find_program_address returns — is safe to use. Non-canonical bumps can also produce off-curve addresses, but there’s nothing stopping two different bumps from both being valid off-curve results for slightly different reasons, and accepting an arbitrary one opens the door to an attacker deriving a second, unintended valid address for the same seeds.
Anchor stores the canonical bump for you the moment you write bump (no value) in an init constraint — it shows up in ctx.bumps.counter inside your handler, and I saved it onto the account itself:
rust
counter.bump = ctx.bumps.counter;
On every instruction after that, I stopped asking Anchor to re-derive the bump from scratch and instead passed the stored one back in:
rust
[account(
mut,
seeds = [b"counter", user.key().as_ref()],
bump = counter.bump,
)] pub counter: Account<'info, Counter>,
Re-deriving is a loop that walks down from 255 doing a hash at each step; reading a stored u8 is free. Once you know the canonical bump, there’s no reason to pay for finding it again.
The full lifecycle
Putting the week together as one sequence, using the same counter account the whole way through:
Derive. find_program_address([b”counter”, user], programId) gives you an address and a bump. Nothing exists there yet — this step is pure computation, no network call.
Initialize. The init constraint creates the account at that address, funded by the payer, sized to hold your struct plus the 8-byte discriminator.
Mutate. Later instructions load the same PDA by re-supplying the same seeds and the stored bump, so Anchor re-derives the address and confirms it matches what you handed it before running your handler logic.
Close. This is the step that doesn’t map cleanly onto a Web2 mental model. close = user doesn’t run a DELETE FROM — it drains every lamport in the account back to the wallet named, zeroes out the account’s data, and marks it for garbage collection at the end of the transaction:
rust
[account(
mut,
close = user,
seeds = [b"counter", user.key().as_ref()],
bump = counter.bump,
has_one = user,
)] pub counter: Account<'info, Counter>,
The handler for close_counter is empty — Ok(()) and nothing else — because all the actual work happens declaratively in that accounts struct, not in the function body. The rent you paid at init time was never spent; it was a refundable deposit compensating the network for the storage bytes, and it comes back in full the moment the account stops existing.
What I would tell past me
The program ID is part of the derivation. The exact same seeds passed to a different program produce a completely different address — the seeds alone don’t own anything.
PDAs cannot sign for themselves. Only the program that owns them can sign on their behalf, and only by supplying the same seeds at signing time. There is no private key anywhere for a PDA, ever.
The seeds/bump constraint isn’t just for finding the account — it’s also the access control. When I tried passing Wallet B’s counter into an increment call signed by Wallet A, Anchor re-derived the expected address from Wallet A’s key, found it didn’t match the account I supplied, and rejected the transaction with a ConstraintSeeds error before any of my handler code ran. I didn’t write that check; the seeds constraint was the check.
has_one is a different, complementary guard — it compares a field stored on the account (like counter.user or config.admin) against a signer you passed in, rather than re-deriving an address. I use both, for different reasons, and it’s worth keeping straight which one is doing which job.
init_if_needed is convenient and also a footgun. Reach for it deliberately, not as a default, because it quietly changes an “only run once” instruction into one that can be called again against existing state.
Further reading
If you want the primary sources instead of my paraphrase of them: the Solana PDA documentation and the Anchor PDA guide cover the same ground with more precision than a week-one blog post can. My counter program is on GitHub if you want to see the whole thing in context.
100DaysOfSolana