A step-by-step guide to versioned, automatic ABI migrations: what you run, what the framework does for you, and what happens on-chain when old data meets a new program.
Seven steps from turning migrations on to shipping a new version. Click each step to expand it. The under-the-hood panels explain the machinery for anyone curious.
Run pina migrations create --auto true once the program address is final. It records the policy in migrations/manifest.json, the only place it lives, and snapshots every contract as version 0. The version counter is u8 unless you add --version-type u16 to that first run. u8 is fine for almost every program, and distinguishes 256 versions per contract.
Accounts and events gain a version envelope. The version number sits immediately after the existing discriminator. The framework owns these bytes, so your code never reads or writes them by hand.
Instructions keep their plain [discriminator][payload] format. The manifest records one snapshot of each ("envelope": false), and the build fails if the struct drifts from it. An instruction that must accept older payloads under the same discriminator opts in with #[instruction(discriminator = X, migrations)] and gains the envelope too.
The encoding (u8, u16, or u32) is program-wide, so every contract shares one envelope size. Generated code picks the matching implementation, not you. The width can change only while nothing is published; the first deployment freezes it.
Nothing about migrations goes in pina.toml. The manifest is the one copy the macros read, so a second copy could only disagree with it, and the retired [migrations] keys fail every command that reads pina.toml, naming the flag that replaces them.
Nothing special here, and that is the point. Say your State account needs a new revision counter:
You do not write a conversion function, bump a constant, or touch stored data. Just edit the struct.
You cannot ship a wire-format change without a migration. The build refuses until the change is recorded, and CI runs the same gate through pina migrations check.
The derive macros snapshot each contract's schema (field names, types, order, layout) at compile time and compare it against a checked-in manifest. When the code and the manifest disagree, you get the compile error you just saw.
Most changes need nothing from you. Appending fields, growing fixed buffers, and widening numbers produce a generated transition with no input.
Two kinds of change ask a question:
On a terminal you answer interactively. In CI or an agent, the same questions arrive as JSON, or as a failure that names the flag to run:
--assume-removed. Every dropped field also prints a warning naming what was discarded.Two files change. migrations/manifest.json records the new version and its full schema snapshot. migrations/transitions/account_1_03/… holds a generated Rust transition that copies overlapping bytes field by field and zero-fills anything new.
migrationsRenames are recorded as byte-preserving moves. If a new answer ever contradicts recorded history, the CLI rejects it instead of guessing. Events never get a transition directory: a changed event appends a version with its own schema, and clients decode each version with that schema. An instruction transition that would zero-fill an added argument is always manual, because the handler could not tell that default from a value a client sent.
Some changes have no safe automatic answer, like changing a field's type, reordering fields, or moving an account from fixed to compact layout. The CLI still records the migration, but generates a stub marked TODO(pina-manual-migration) that blocks the build until you write the body. Its header prints every field's stored and destination offsets, relative to the version envelope:
pina migrations check fails while any TODO remains, so CI can enforce completion. Everything around your body stays framework-owned: validating the old bytes, validating the result, the version bump, the rent math. If you change the draft's layout again after writing the body, the next create moves your body to v0_to_v1.rs.stale and writes a fresh stub for the new offsets, so an old body can never compile against a layout it was not written for.
Deploy appends a receipt to publications.json recording which ELF bytes went on-chain and pinning the schema hash of every version they carried. Receipts are appended in deployment order and the file lives in version control, which is what keeps the history from being edited or reordered later.
Once a version is published it becomes immutable. Its schema snapshot and transition files are frozen, and any later edit to them fails pina migrations check. Shipped migrations are history, not drafts. If a deploy dies halfway, pina migrations reconcile inspects the pending receipt and either completes it or abandons it explicitly. An abandoned receipt still freezes whatever shipped.
Every generated encoder stamps the current version into the envelope automatically. Your client code constructs instructions and decodes accounts exactly as before.
Every enveloped wire format carries the framework-owned version after its discriminator: accounts, and the payload of an instruction that opts into migrations. When those bytes arrive, the program routes on the version. This diagram is the entire decision tree.
PublishedPayloadChanged; declare a new discriminator), appending optional accounts is recorded in place, and it cannot gain an envelope (EnvelopeAddition). An instruction with migrations takes the stale branch: #[discriminator(entrypoint)] routes it through the generated process_versioned, which converts the payload and then calls ProcessAccountInfos::process_from_version(self, data, source_version); the default forwards to process(data), so the handler sees the current layout. Removing an envelope after publication fails with EnvelopeRemoval. Events never enter the program: it emits only the current version, and clients decode older log records with the schema of the version that wrote them.Account migrations are the interesting case because they can change the account's size, and size changes rent. This is what one instruction does when the account is two versions behind.
max_lamports). An undersized cap fails the transaction with MigrationLamportBudgetExceeded, which names the constant to raise; pina migrations create prints the same deficit estimate before you deploy. Shrinking migrations charge nothing and never refund; the lamports stay in the account. The instruction rewrite itself costs compute units in the same transaction, bounded by MAX_INLINE_STEPS and a 1 KiB stack workspace. Generated code does not compile if a payload needs more than that.The division of labor is intentional. Clients write versions. Programs migrate data. A client never rewrites an account and never needs to know a migration exists.
The everyday case. Bytes already match, so the program validates and runs. Costs exactly what a program without migrations would cost.
FAST PATHThe program migrates any accounts the request touches inline in the same transaction, and normalizes the request itself when its instruction opts into migrations. The old client just sees the transaction succeed.
JUST WORKSThe version is from the program's future, so it fails with a clear version error instead of misreading bytes. Deploy the new program first. A client ahead of its program always fails loudly, never with a silent misread.
FAILS LOUDLYClassic behavior, untouched. Migrations only change things when the program is ahead of the data.
UNCHANGED| Responsibility | Client | Program |
|---|---|---|
| Stamp the current version into anything it sends | Yes, automatic in generated encoders | n/a |
| Rewrite stale accounts or migration-aware instruction payloads | Never | Always, inline in the touching transaction |
| Decode historical events | Yes, each version with its own generated decoder | Emits only the current version; never rewrites a log |
| Know the migration history | Never. It lives in migrations/ and in the program | Owns it |
| Fund a rent deficit when an account grows | Passes the payer account (old clients omit it) | Charges within a developer-set cap |
| Reject data from the future | Generated decoders enforce the envelope in TypeScript, Dart, and Rust, with an error that names the version it found and what to do | Always fails closed |
A typed reader refuses a stale account: the load fails with a "migration required" error whether the account was passed writable or read-only. Migration itself needs a writable position, because it rewrites bytes and may resize and fund the account. So a stale account that your program only reads keeps failing until one transaction migrates it in a writable slot. The clean fix is a small instruction whose only job is migrating: the sweep.
Write one sweep per program with its own discriminator. Every migratable account appears as an optional, writable view, the payer funding growth appears once, and the system program the rent transfers invoke is declared:
The client sends the sweep first, then the real instruction, in the same transaction:
First, not last. Instructions run in order and any failure aborts the whole transaction. The real instruction loads the stale account and fails with MigrationRequired, so a sweep placed after it never runs. A sweep sent alone, with no business instruction, is a maintenance transaction that pre-migrates accounts ahead of future use — that standalone form is the only trailing sweep worth sending.
max_lamports cap the developer sets; shrinking never moves lamports. And since each caller supplies its own payer, nobody can be made to fund someone else's migration.State::try_from_bytes_versioned(bytes) validates the exact representation named by the envelope and returns the generated StateVersioned enum — one variant per historical version plus Current. It never writes, resizes, or needs a writable borrow, and foreign, unknown, future, or malformed bytes fail closed. This is a deliberate trade-off, not a replacement for migration: the caller must handle every stored representation in its logic, which is the branching migrations exist to remove. Prefer the sweep whenever a writable touch is schedulable.| Case | What the sweep does |
|---|---|
| Account absent (client omitted it or sent the program-address filler) | Skipped entirely; no validation, charge, or write |
| Already current | Ownership, writability, and discriminator checks, a version-envelope inspection and current-layout validation; no writes, no rent, no CPI — safe to send speculatively |
| Stale, same size | Rewritten in place, destination validated, current version committed |
| Stale, growing | The payer tops up only the rent deficit for the size the step needs, capped by max_lamports; the account is resized, rewritten, and committed |
| Stale, shrinking | Rewritten and shrunk. Lamports stay in the account; there is no refund today |
| Any step fails | Whole transaction rolls back, including every earlier sweep in the same instruction |
Failures land in front of the first mutation. A growing step with no payer fails with MigrationRequired, and a deficit above the cap fails with MigrationBudgetExceeded — the account stays stale, never half-migrated. A read-only or foreign-owned account fails the writability or ownership check before the version is even read, a wrong discriminator fails with InvalidAccountData, and a version from the program's future fails with InvalidMigrationVersion instead of guessing at the layout.
Two details are worth calling out. On refunds: shrinking keeps every lamport in the account by design. Refunding would mean picking a recipient when the funder and the owner differ, and it would add a transfer per step. A refund-to-payer variant could exist later, but the current rule is simple and safe: money only ever moves inward. And on surplus bytes: they stay on the account, exactly as stored.
Migrate instruction described below is the framework-owned sweep: MigrateContext runs the same [payer, systemProgram, …accounts] layout through the same MigrateAccount executor, and run_optional skips program-address placeholders and past-the-end slots. Generated clients already emit the Migrate composer and per-account needsMigration checks, so no metas are hand-composed; the one-call migrateIfNeeded wrapper (#339) is the remaining convenience. A hand-written sweep stays compatible because it drives the same executor and produces the same state: keep it when the handler needs policy the generated route does not carry.Everything a developer or an agent needs, in one table.
| Command | When | What it does |
|---|---|---|
pina migrations create | After changing a struct | Records the new version and generates transitions. --auto and --version-type record the policy, usually once on the first run. Asks questions on a terminal; add --rename from:to, --assume-removed field, --no-interactive, or --json to answer without one. |
pina migrations check | CI, pre-commit | Non-mutating gate. Catches drift, unfinished TODO transitions, and any edit to frozen published files. Exits 1 with a readable reason. |
pina migrations status | Curiosity, support | Reports each contract's local version and publication state. --json for tooling. |
pina migrations reconcile | A deploy died halfway | Inspects the pending publication receipt, then completes it or abandons it with --abandon. |
pina build | Always | The drift gate. Fails until every schema change is recorded and every TODO transition is filled. |
pina deploy --cluster <CLUSTER> | Shipping | Deploys and appends a receipt pinning the shipped versions, which freezes them. Loopback targets record one only with --record-publication. |
pina generate | After create | Regenerates the TypeScript, Dart, Rust, and CPI clients with the new shape and current version constants. |
MAX_INLINE_STEPS adjacent versions are walked in one instruction; an account further behind fails with MigrationUnavailable, so publish smaller, more frequent versions. The normalization workspace is capped at 1 KiB of stack (MigrationWorkspaceExceeded past that, and generated code does not compile past it). Growth is capped by MAX_PERMITTED_DATA_INCREASE per instruction (MigrationAccountGrowthExceeded), measured from the size captured before the whole inline ladder and not per hop, and by the payer's max_lamports budget overall (MigrationLamportBudgetExceeded). Exceeding any of these fails the transaction. It never silently skips a migration. Older builds reported the workspace, growth, and lamport-budget failures as the legacy aggregate MigrationBudgetExceeded; MigrationUnavailable was already its own code and was not part of that split.publications.json pins the SHA-256 of every published schema and transition and freezes on publish, so accidental drift cannot slip through. It is not a signature scheme: anyone with write access to the repo can rewrite it, which is why it carries no hash chain and relies on version control to stay append-only. Treat it as a build-time integrity check, not as proof of what happened on-chain.Pina reserves the all-ones value of every instruction discriminator width for a framework migration instruction, and #[discriminator] rejects user variants that claim it at compile time. A program with migratable accounts routes it before parsing its own enum:
Slot 0 is a writable payer funding the rent deficits (or the program address when no step needs funding), slot 1 is the system program the transfers invoke, and every later slot is a program-owned, self-describing account. A slot holding the program address, or an index past the end of the list, is skipped — so a client sends only the accounts it needs to migrate. MigrateContext validates ownership, rejects duplicated slots, migrates each slot at most once, and applies the same step, growth, and lamport caps as the inline path.
That is the client flow the sweep section above describes, now with a framework-owned entry point: prepend [Migrate { payer }, …real instructions] and the payer authorizes exactly the migration cost.
The TypeScript, Dart, and Rust clients generate the pieces of that flow so you never compose metas by hand. Each migratable account module gains the generic needsMigration envelope check as a per-account helper (stateNeedsMigration(bytes) for a State account) — true only when the bytes name this account's discriminator and a version older than the client's schema — plus a <Account>MIGRATION_VERSION constant. Each program gains a Migrate composer that always sends the payer and system program slots (the system program is the default for slot 1) and whose migratable slots are all optional: omitted slots become program-address placeholders and trailing omitted slots are truncated, so a client sends only the accounts it needs. Versioned events are decoded, never converted: the IDL lists each earlier version as its own event, <Event>V<n> (for example ValueChangedEventV0), with that version's own codec. The program-level log parser (parse<Program>EventsFromLogs in TypeScript and Dart) dispatches each record by discriminator and version and throws on a version no generated event describes, naming the version and telling you to regenerate the client. Nothing is projected into the current shape or zero-filled. In Rust, each version's try_from_bytes tells a stale record from a future one and points at the event generated for the other version. The program-level parser decodes a line only while this program is the innermost invocation in the transaction's logs, so another program cannot forge its events.
On-chain state inspection (#337). A command to read an account's version envelope over RPC and report "stored v0, current v2, about two steps, roughly this many CU, needs this many lamports". There is currently no way to see on-chain migration state from the CLI.
One-call migration (#339). A migrateIfNeeded() wrapper that fetches, checks the envelope, and sends the Migrate instruction in one call over an RPC handle. The per-account checks and the Migrate composer already ship in all three clients.
Persisted answers (#340). Disambiguation decisions saved next to the manifest, so fresh clones and CI can answer rename questions without replaying flags.
Cost preview (#341). Reuse the static SBF profiler to show the rent and compute cost of a migration before you ship it.
Disambiguation answers can also live in the repo: a [migrations.answers] table in pina.toml is consulted before prompting, command-line flags override it per field, and a flag that contradicts a persisted rename fails closed — so fresh clones and CI replay an answer made locally without re-deriving flag lists.
Also see docs/src/migrations/flow.md and ADRs 0007 and 0008 in docs/src/adrs/.