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
idnever changes once a release with it has been deployed anywhere. compat.platformApimust 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.
| Mode | Runs | Use it when |
|---|---|---|
blocking | During 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. |
background | After 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>andrecords.write:<type>for records;db:rawfor$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 | |
|---|---|
cursor | Where 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, environment | The 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:
- Add the new structure, keeping the old.
- Migrate the data.
- 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--yesalone 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 migration | The tenant configuration's | |
|---|---|---|
| Runs | During the app's deploy | During jetstack tenant apply or a promotion (from git or from the UI) |
| As | The app, limited by its capabilities | The person who applied the tree |
blocking means | Before the release goes live | Before the apply removes anything |
| Waiting for approval | The deploy, under Deployments on the app's page | The 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>.phpfrom 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 --againruns it from the start;jetstack migrations reset <id> --app crmmakes 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.
- Expand. The release adds
first_nameandlast_nameto theclienttype, and keepsname. - Migrate.
jetstack migration new split-client-names --app crmwrites the file. The batch reads 200 clients at a time, ordered by id, sets the two new properties and saves. The manifest grantsrecords.read:clientandrecords.write:client. The mode isblocking, because the new screens show only the new fields. - Test. On development, the test creates a client named "Ada Lovelace", runs
$this->migrate(...)and checks the two fields. - 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.
- Contract. A later release stops declaring
name, and is deployed with removals allowed.
Best Practices
- Prefer
backgroundfor anything that grows with the data. Keepblockingfor 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 validatereports a file there that the manifest does not declare.