Migrations
Change your schema while keeping older clients working.
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 usable 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 and lenses
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.
Interactive Demo
Choose data from the left, then pick a schema version on the client device. The row is loaded as-is, and the client applies all the lenses needed to interpret it using its schema.
a01f5c
title: s.string()
311995
title: s.string() completed: s.boolean()
73b65d
title: s.string() done: s.boolean()
Rows retain the physical schema identity under which they were written. A read in another compatible schema uses lenses to project those rows into the requested shape. 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).
-
Deploy — publish the schema, permissions, and required migrations:
pnpm dlx jazz-tools@alpha deploy <appId>Both
schema.tsandpermissions.tsmust be present. First deployments and permission-only changes need no migration; compatible schema changes can use an inferred migration. Changes requiring row transformations need a reviewed migration path.deployhandles the release in this order:- Loads and validates the schema and permissions, then checks the migration path from the currently deployed schema. Missing required migrations fail before publication.
- Stores any missing schemas and migrations. A reviewed local chain can span multiple schema versions; an existing server path or an inferred compatible migration can also satisfy the transition.
- Publishes the permissions for the target schema last, checking that another deploy has not changed the permissions head in the meantime.
A network failure can leave schemas or migrations stored without advancing the permissions head. Retrying reuses stored artifacts. If another deploy wins the permissions-head check, review that release before retrying yours.
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.uuid().optional(),
projectId: s.uuid().optional(),
owner_id: s.string(),
},
{
parent: s.rel("todos", "parentId"),
children: s.reverse("todos", "parent"),
project: s.rel("projects", "projectId"),
},
),
},
to: {
todos: s.table(
{
title: s.string(),
done: s.boolean(),
description: s.string().optional(),
parentId: s.uuid().optional(),
projectId: s.uuid().optional(),
owner_id: s.string(),
},
{
parent: s.rel("todos", "parentId"),
children: s.reverse("todos", "parent"),
project: s.rel("projects", "projectId"),
},
),
},
});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.uuid().optional(),
projectId: s.uuid().optional(),
owner_id: s.string(),
},
{
parent: s.rel("todos", "parentId"),
children: s.reverse("todos", "parent"),
project: s.rel("projects", "projectId"),
},
),
},
to: {
todos: s.table(
{
title: s.string(),
done: s.boolean(),
description: s.string().optional(),
parentId: s.uuid().optional(),
projectId: s.uuid().optional(),
owner_id: s.string(),
},
{
parent: s.rel("todos", "parentId"),
children: s.reverse("todos", "parent"),
project: s.rel("projects", "projectId"),
},
),
},
});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.uuid().optional(),
projectId: s.uuid().optional(),
owner_id: s.string(),
legacy_priority: s.int(),
},
{
parent: s.rel("todos", "parentId"),
children: s.reverse("todos", "parent"),
project: s.rel("projects", "projectId"),
},
),
},
to: {
todos: s.table(
{
title: s.string(),
done: s.boolean(),
description: s.string().optional(),
parentId: s.uuid().optional(),
projectId: s.uuid().optional(),
owner_id: s.string(),
},
{
parent: s.rel("todos", "parentId"),
children: s.reverse("todos", "parent"),
project: s.rel("projects", "projectId"),
},
),
},
});Adding relations to existing UUID columns
To add a relationship using an existing ownerId UUID column, declare owner: s.rel("users", "ownerId") in the table's relationships and follow the migration workflow above. Existing UUID values stay unchanged.
Migrating historical schemas
If your database already contains rows under a historical schema that is disconnected from other schema versions, those rows remain stored but cannot be read through a schema with no migration path to them. Create a migration to connect that historical schema to the schema your app uses.
This is a recovery workflow for existing disconnected schemas. deploy requires a complete
migration path when changing the deployed schema; it does not let you skip a required 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/.
Inspecting the local schema hash
pnpm dlx jazz-tools@alpha schema hash prints the hash of the local schema.ts without contacting a server or writing a snapshot, giving you a simple way to compare your local schema against what's deployed.
pnpm dlx jazz-tools@alpha schema hashExporting 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