Data Migrations

What It Is

A data migration is a piece of an app's code that moves or transforms existing records when a release changes the data model. Examples:

  • filling a new property from an old one;
  • splitting one field into two;
  • merging two types;
  • recomputing stored totals.

A migration ships inside the app release that needs it. Each environment runs it once, when the release is deployed there, and records that it ran. Promoting the release to production therefore runs exactly the migrations production has not run yet.

The platform has migrations of its own too: they run when an environment is upgraded to a new platform version (see Isolated Deployment). This chapter is about the migrations you write. They come in two kinds:

  • An app's migrations ship in its release. Most of this chapter is about them.
  • The tenant configuration's migrations live in the tree, in tenant/migrations/. They belong to changes made to the shared data model rather than to an app. See Migrations In The Tenant Configuration.

Why It Matters

Changing the data model is only half of a release. Without migrations, existing records would have to be fixed by hand on every environment, with SQL or imports nobody can review, repeat or trace. A migration is:

  • Reviewed like code. It lives in the app's repository, next to the change that needs it.
  • Tested on development. It runs there first, and it can have its own test.
  • Explicit on production. The plan of every deploy and promotion lists the migrations it will run, and the person deploying confirms them.
  • Recorded. Each environment keeps a ledger of what ran, when, how many records it processed and whether it failed.

How It Works

Declared In The Manifest

A migration is a .php file in the app's migrations/ folder, declared in the manifest's migrations list:

"compat": { "platformApi": ">=4" },
"migrations": [
  {
    "id": "2026-10-12-split-client-names",
    "file": "migrations/2026-10-12-split-client-names.php",
    "mode": "blocking",
    "description": "Fill first_name and last_name from name"
  }
]
  • The list runs in order.
  • The id never changes once a release with it has been deployed anywhere.
  • compat.platformApi must be at least 4, so that an older platform refuses the release instead of ignoring its migrations.

Blocking Or Background

Every migration chooses when it runs; there is no default.

ModeRunsUse it when
blockingDuring the deploy, after the release's configuration is applied and before the release goes live. The current release keeps serving meanwhile.The new code cannot work until the data is in the new shape. Keep it short.
backgroundAfter the release goes live, in batches from the scheduler (at most 20 seconds per minute).There is a lot of data, and the new code can work while it runs.

A deploy whose blocking migrations take longer than one request is shown as migrating until they finish, then goes live. The CLI follows it; so does the app's page. Such a deploy can be cancelled: the current release stays live, and the next deploy of the release carries on where it stopped.

Code that relies on a background migration's result checks it first: $jetstack->migrations->done('<id>') (see the Internal API Reference).

What A Migration Runs As

A migration runs in the same sandbox as the app's handlers, as the app. It can only do what the manifest's capabilities allow:

  • records.read:<type> and records.write:<type> for records;
  • db:raw for $jetstack->db->query.

Its writes are quiet: they fire no app events, no tenant automations and no code hooks, so a backfill never sends e-mails or starts workflows. A migration whose writes should trigger them sets "triggers": true.

In Batches

The file runs once per batch. It reads where the previous batch stopped ($migration->cursor), does a bounded amount of work, then either asks for another batch ($migration->next([...])) or finishes ($migration->done(), or simply returns).

  • Each batch's position is saved, so an interrupted migration resumes where it stopped instead of starting over.
  • Every batch must be safe to repeat: one that was cut short is run again.
$migration
cursorWhere the previous batch stopped, as it was passed to next(); null the first time
next(array $cursor)More is left: run again from this cursor
done()Finished
progress(int $processed, ?int $total = null)Records handled in this batch; with the total, the status shows a percentage
log(string $line)A line in the app's log
secondsLeft()Time left before this batch should hand back (timeoutMs, 60 s by default)
id, environmentThe migration's id, and development or production

Failures

  • A blocking migration fails: the deploy fails and the previous release stays live. The batches already done stay done; the next deploy of the release continues from the failed batch.
  • A background migration fails: it is retried after 1, 5, 30 and 120 minutes. After that it waits until someone retries it (jetstack migrations retry). Later background migrations of the same app wait for it.
  • Removals wait. While a release's background migrations are unfinished, a deploy does not remove the types, properties and roles the release no longer declares, even with removals allowed, because a migration may still read them. Deploy again once they have finished.

No Way Back

Migrations only go forward. Rolling an app back switches its code and configuration, but data a migration changed stays changed:

  • the rollback says which migrations stay applied;
  • a rollback never runs migrations: a release with migrations that have not run in that environment cannot be rolled back to, only deployed.

This is why releases follow expand → migrate → contract:

  1. Add the new structure, keeping the old.
  2. Migrate the data.
  3. Remove the old structure in a later release, once nothing reads it.

The previous release then still works on the migrated data, and rolling back stays safe.

On Production

A migration reaches production with its release: through jetstack deploy --env prod, or as part of a promotion. In both cases the plan lists each migration that will run there, and when. Then the tenant's productionMigrationApproval decides:

  • Confirmation (the default). The person deploying confirms the migrations explicitly: a checkbox in the browser, or a question of its own in the CLI. A script passes --allow-migrations, because --yes alone is not enough. Without it, production answers 428 and nothing changes.
  • A second person's approval. The deploy waits in waiting for approval. Another person reviews the plan and each migration's code (Review and approve on the app's page, or jetstack approve --show-code) and approves it.

Wherever deploys of the app need approval anyway, the approval covers the migrations too. Any change to their code voids it.

Production asks about each migration once. Later deploys and applies do not ask again about a migration it already let run, as long as its code is unchanged:

  • a background migration still going;
  • a blocking one that failed and runs again.

The plan shows such a migration as already queued here. New code in a migration that has not finished is asked about again.

Migrations never run on production any other way: there is no command that runs one directly.

Migrations In The Tenant Configuration

When the data model is built in the tenant rather than in an app's manifest, a migration travels with the tenant's configuration. It is a pair of files in tenant/migrations/: the declaration (<id>.json: mode and description) and the code (<id>.php).

jetstack migration new <name> --tenant writes both. They run the same way as an app's migrations, with these differences:

An app's migrationThe tenant configuration's
RunsDuring the app's deployDuring jetstack tenant apply or a promotion (from git or from the UI)
AsThe app, limited by its capabilitiesThe person who applied the tree
blocking meansBefore the release goes liveBefore the apply removes anything
Waiting for approvalThe deploy, under Deployments on the app's pageThe apply, on production's History screen, or jetstack approve --tenant

A promotion applies the tree first. If the tree's migrations wait for a second person's approval, the apps wait too: promote again once it is approved.

How To Use It

Write One

jetstack migration new split-client-names --app crm               # blocking
jetstack migration new rebuild-order-totals --app crm --background

This does three things:

  • writes migrations/<date>-<name>.php from a template;
  • declares it in the manifest (and sets compat.platformApi);
  • writes a test, tests/<Name>MigrationTest.php.

Replace the description's "TODO", then write the batch.

Test It On Development

jetstack deploy --app crm                           # development runs it
jetstack migrations --app crm                       # where it stands
jetstack test --filter SplitClientNamesMigration    # the test: seed, migrate, check

In a test, $this->migrate('<id>') runs the migration from the start on development (deploy first). While writing a migration, run it by hand:

  • jetstack migrations run <id> --app crm --again runs it from the start;
  • jetstack migrations reset <id> --app crm makes the next deploy run it again.

Both work on development only.

Ship It

Promote as usual (jetstack promote --to prod, or "Promote to production" on development's History screen). The plan shows the migrations; check them with the person who confirms the promotion. Afterwards:

jetstack migrations --env prod --app crm

Example

A CRM keeps clients' full names in name; the new release shows first and last names separately.

  1. Expand. The release adds first_name and last_name to the client type, and keeps name.
  2. Migrate. jetstack migration new split-client-names --app crm writes the file. The batch reads 200 clients at a time, ordered by id, sets the two new properties and saves. The manifest grants records.read:client and records.write:client. The mode is blocking, because the new screens show only the new fields.
  3. Test. On development, the test creates a client named "Ada Lovelace", runs $this->migrate(...) and checks the two fields.
  4. Ship. The promotion's plan lists "split-client-names — runs before going live". Production runs it during the deploy and then switches to the new release.
  5. Contract. A later release stops declaring name, and is deployed with removals allowed.

Best Practices

  • Prefer background for anything that grows with the data. Keep blocking for what the new code truly cannot work without.
  • Make every batch safe to run twice. Write the target value; never increment blindly.
  • Never edit a migration that has run in production. It will not run again there. Write a new one.
  • Keep migrations in the manifest while any environment may still need them. Remove them only when every environment has run them.
  • Keep helpers in server/. migrations/ holds the migration files only; jetstack validate reports a file there that the manifest does not declare.