Migrations
Structural schema evolution workflow and reviewed migration edges.
You can skip this first
If you are trying to get your first app running, you can skip this page and return later.
Why Jazz migrations are different
Most migration systems are one-way: rewrite every row to the new shape, then cut over. That assumes you can stop the world long enough to upgrade — which doesn't hold when your clients are local-first, frequently offline, and updating their app on their own schedule.
Jazz keeps every schema version addressable by hash and translates rows between them on read and write. Clients on different versions stay interoperable, and nothing on disk is rewritten when you ship a new schema.
Schemas, lenses and branches
Every unique version of your schema.ts has a hash which can be used to refer to it. When creating migrations, you describe the changes required to move between two schema versions. This is known as a 'lens'. Fetching all intermediate lenses allows clients with any published schema version to read data created with any other published schema version.
Storage is partitioned by schema hash: rows written under a given schema live in
that schema's own branch (env-{hash}-userBranch) and stay there unchanged.
Non-adjacent reads compose lenses in sequence to bridge multiple schema versions.
In practice, this lets you:
- Ship a schema change without waiting for every user to update their app.
- Roll out platform-by-platform (mobile, desktop, web) on independent cadences.
- Accept writes from clients that have been offline since before the new schema landed.
Workflow
-
If this is the first migration you are creating, run:
pnpm dlx jazz-tools@alpha migrations createThis creates an initial snapshot of your schema in
migrations/snapshots/. No migration file is created yet because there is no previous schema to diff against. -
Edit
schema.ts— change the data shape as needed. -
Validate locally — optionally run
pnpm dlx jazz-tools@alpha validateto surface any policy diagnostics without publishing.deployruns the same checks;validateis most useful as a fast pre-publish sanity check or in CI. -
Create a migration stub for the updated schema — run:
pnpm dlx jazz-tools@alpha migrations create --name <your-migration-name>By default, Jazz diffs the latest committed snapshot in
migrations/snapshots/against the current schema and writes a stub migration file intomigrations/. It also saves a snapshot of the generated schema. -
Review and customise — the migration, if needed (see below).
-
Publish — push the migration to the server:
pnpm dlx jazz-tools@alpha migrations push <appId> <fromHash> <toHash>If you also want to publish the current schema, the migration and permissions in one step, you can run:
pnpm dlx jazz-tools@alpha deploy <appId>deploywalks through the publish pipeline in one go:- Publishes the current schema if the server does not already have it.
- If your previous
permissions.tswas tied to an older schema hash, asks the server whether it already has a migration path between the two hashes. If not, pushes the local migration file that closes the gap (and fails with a helpful message if you haven't created one yet). - Publishes the current permissions, attached to the current schema hash.
Permission-only changes in permissions.ts don't need a migration but still need to be deployed:
pnpm dlx jazz-tools@alpha deploy <appId>. See Permissions for details.
The migration file
The generated stub describes the diff as declarative operations which carry enough information to run in either direction. That is how older clients can still read data written under a newer schema: the same operations replay in reverse.
If the diff contains ambiguities (e.g. a column was removed and a same-typed column was added, which could be a rename), the generated lens is marked as a draft. Draft lenses will fail at startup if they are in the path to a live schema. You need to review the draft lens and resolve the ambiguity before publishing.
Generated stub
Here's a generated stub for adding a description column:
import { schema as s } from "jazz-tools";
export default s.defineMigration({
migrate: {
todos: {
description: s.add.string({ default: null }),
},
},
fromHash: "a01f5c72ec47",
toHash: "311995e9a178",
from: {
todos: s.table({
title: s.string(),
done: s.boolean(),
parentId: s.ref("todos").optional(),
projectId: s.ref("projects").optional(),
owner_id: s.string(),
}),
},
to: {
todos: s.table({
title: s.string(),
done: s.boolean(),
description: s.string().optional(),
parentId: s.ref("todos").optional(),
projectId: s.ref("projects").optional(),
owner_id: s.string(),
}),
},
});Customising defaults
Review generated defaults before you publish. For example, you might replace a nullable default with a domain-specific value:
import { schema as s } from "jazz-tools";
// Example of editing a generated migration stub.
export default s.defineMigration({
migrate: {
todos: {
description: s.add.string({ default: "No description" }),
},
},
fromHash: "a01f5c72ec47",
toHash: "311995e9a178",
from: {
todos: s.table({
title: s.string(),
done: s.boolean(),
parentId: s.ref("todos").optional(),
projectId: s.ref("projects").optional(),
owner_id: s.string(),
}),
},
to: {
todos: s.table({
title: s.string(),
done: s.boolean(),
description: s.string().optional(),
parentId: s.ref("todos").optional(),
projectId: s.ref("projects").optional(),
owner_id: s.string(),
}),
},
});Backwards defaults
When a newer schema drops a column that older clients still expect, define a backwards default so the lens can supply a value for those clients:
import { schema as s } from "jazz-tools";
// Example: dropping a column with a backwards default.
// Clients still on the older schema continue seeing legacy_priority.
export default s.defineMigration({
migrate: {
todos: {
legacy_priority: s.drop.int({ backwardsDefault: 0 }),
},
},
fromHash: "311995e9a178",
toHash: "73b65d082ab8",
from: {
todos: s.table({
title: s.string(),
done: s.boolean(),
description: s.string().optional(),
parentId: s.ref("todos").optional(),
projectId: s.ref("projects").optional(),
owner_id: s.string(),
legacy_priority: s.int(),
}),
},
to: {
todos: s.table({
title: s.string(),
done: s.boolean(),
description: s.string().optional(),
parentId: s.ref("todos").optional(),
projectId: s.ref("projects").optional(),
owner_id: s.string(),
}),
},
});Migrating historical schemas
Jazz does not require you to create a migration for every schema change. You can just use the app and create new data. Existing data will still be stored in the database, but you will not be able to read it until you create a migration.
This is particularly useful when you are iterating on a feature that is not yet ready to be released.
The Jazz server will detect when there are rows that are not reachable from the current schema. It will log a warning and suggest you create a migration.
To do so, you'll need to create the migration using explicit to/from schema hashes:
pnpm dlx jazz-tools@alpha migrations create <appId> --fromHash <fromHash>
pnpm dlx jazz-tools@alpha migrations create <appId> --fromHash <fromHash> --toHash <toHash>--toHash defaults to the current local schema. When a requested hash is not already saved locally, Jazz resolves it from the
server and saves a snapshot in migrations/snapshots/.
Exporting the compiled schema
pnpm dlx jazz-tools@alpha schema export prints the compiled structural schema as JSON to stdout. It
also saves a snapshot of the schema in the local snapshot directory.
pnpm dlx jazz-tools@alpha schema export
pnpm dlx jazz-tools@alpha schema export --schema-dir ./packages/app
pnpm dlx jazz-tools@alpha schema export <appId> --schema-hash <hash> --server-url http://localhost:4200 --admin-secret <secret>Without --schema-hash, Jazz exports the current local schema.ts. With --schema-hash, it
loads the schema from the local snapshot folder or, if missing, from the server.
--schema-dir and --schema-hash are mutually exclusive.
Server-backed commands require the app id so Jazz can resolve app-scoped routes like
/apps/<appId>/schema/:hash.
| Flag | Default | Description |
|---|---|---|
--schema-dir <path> | current directory | Path to app root containing schema.ts |
--schema-hash <hash> | none | Export a stored structural schema by hash |
--migrations-dir <p> | ./migrations | Path to migrations directory and snapshot folder |
--server-url <url> | JAZZ_SERVER_URL | Server URL used when --schema-hash is not available locally |
--admin-secret <sec> | JAZZ_ADMIN_SECRET | Admin secret used when --schema-hash is not available locally |
Migration flags
migrations create uses flags rather than positional hashes. When it needs to resolve missing
schema hashes from a server, pass <appId> as the leading positional argument.
| Flag | Default | Description |
|---|---|---|
--schema-dir <path> | current directory | Path to app root containing schema.ts |
--migrations-dir <p> | ./migrations | Path to migrations directory and snapshot folder |
--server-url <url> | JAZZ_SERVER_URL | Server URL used when resolving missing schema |
--admin-secret <sec> | JAZZ_ADMIN_SECRET | Admin secret used when resolving missing schema |
--fromHash <hash> | latest snapshot | Optional source schema hash |
--toHash <hash> | current schema | Optional target schema hash |
--name <name> | unnamed | Optional migration filename label |
Next steps
- Defining Tables — table and column definitions
- Column Types — full list of available column types