Skip to content

Migrations

Migrations without an ORM: commit .sql files to a directory and wire it into the database resource with migrations. Each deploy applies the pending files — there is nothing else to run. For Durable Objects, load the files during construction and apply them per instance at activation instead.

-- migrations/0001_init.sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
-- migrations/0002_posts.sql
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
title TEXT NOT NULL
);
const db = yield* Cloudflare.D1.Database("app-db", {
migrations: "./migrations",
});

The same prop exists on Neon.Project, Neon.Branch, Fly.Postgres, and the PlanetScale databases and branches.

  • The directory is scanned recursively for .sql files.
  • Files are ordered by numeric prefix (0001_init.sql, 0002_posts.sql), then by name.
  • Each file is content-hashed and applied files are recorded, so a deploy only runs what’s new — an unchanged directory is a noop.
  • Application is ordered and stops on the first failure; the failed file is retried on the next deploy.
  • Applied history only moves forward; see Change migration history safely.

Applied migrations are recorded in one Alchemy-owned table, __alchemy_migrations, on every database:

CREATE TABLE IF NOT EXISTS "__alchemy_migrations" (
id SERIAL PRIMARY KEY, -- INTEGER on SQLite
hash text NOT NULL, -- sha256 of the file
created_at bigint, -- millis from a timestamp prefix, if any
name text, -- the applied-detection key
applied_at timestamp with time zone DEFAULT now()
);

Detection is by name, and hash checks that an applied file hasn’t changed since it ran.

To put the bookkeeping in a differently-named table, use the object form of the prop:

const db = yield* Cloudflare.D1.Database("app-db", {
migrations: {
dir: "./migrations",
table: "my_migrations", // default: "__alchemy_migrations"
},
});

Every database, old or fresh, must end up running the same files in the same order. Alchemy enforces that with two rules, checked before any SQL runs.

An applied file can’t be edited or deleted. A deploy that finds one fails with RewrittenMigrationHistoryError. Add a new file that makes the change instead:

-- migrations/0007_posts_user_index.sql
CREATE INDEX posts_user_id ON posts (user_id);

A new file must sort after every file the database has already applied. Otherwise this database runs it last while a fresh database runs it in its sorted place, and the two schemas can drift. Such a deploy fails with NonLinearMigrationHistoryError:

Pending migration 0004_tags.sql in "__alchemy_migrations" sorts before
the already-applied 0005_users.sql, so it would run in a different order
here than on a fresh database.

This happens when two branches each add a migration and the older-numbered one merges second. Rename the file so it sorts last:

Terminal window
git mv migrations/0004_tags.sql migrations/0006_tags.sql

Renaming is safe even on databases that already ran the file. When an applied file’s name is gone and another file has byte-identical contents, Alchemy treats it as moved: the row in __alchemy_migrations takes the new name and the file doesn’t run again.

0004_tags.sql applied, file gone ┐
0006_tags.sql, same sha256 ┘→ row renamed to 0006_tags.sql, nothing runs

So after the git mv above, a database that never ran the file runs 0006_tags.sql as its newest migration, and a database that did run it only updates its row. Each applied row pairs with at most one file. If the contents changed too, it’s an edit and the deploy fails with RewrittenMigrationHistoryError.

A database that ran the file under its old name but not the files that now sort before it, such as a preview database that ran 0004_tags.sql but never 0005_users.sql, then fails with NonLinearMigrationHistoryError. It really did run them in a different order than a fresh database would. Recreate it.

A database previously migrated with drizzle-kit, Prisma, or wrangler needs no baselining. On the first deploy, Alchemy finds the old tool’s tracking table — __drizzle_migrations (in the drizzle schema on Postgres), _prisma_migrations, or d1_migrations — copies its applied history into __alchemy_migrations once, and leaves the old table frozen: never written, never dropped. Only migrations the old tool hadn’t applied yet actually run.

This is a one-way move. After adoption, Alchemy’s table is the only bookkeeping — the old tool’s table stops reflecting reality, so stop running its migrate command.

Two guard rails:

  • Every recorded row must match a local migration file by name and content hash. An orphaned row fails conversion with MigrationHistoryConflictError. After conversion, the history rules apply.
  • A failed Prisma migration (finished_at NULL) blocks adoption until repaired with prisma migrate resolve; rolled-back rows are skipped.

Databases migrated by older versions of Alchemy are upgraded to the current table shape in place, at whatever table name their state recorded — no action needed.

Each database documents its own mechanics — transactionality and quirks:

Database Mechanics
Cloudflare D1 D1 migrations — batched application (no transactions over HTTP)
Neon Neon migrations — applied transactionally on the branch
PlanetScale PlanetScale migrations — the same contract on Postgres and MySQL branches
Fly Managed Postgres Fly Postgres — applied over the direct URI

Generated migrations target the same contract

Section titled “Generated migrations target the same contract”

Generate and commit files with drizzle-kit before deployment:

Terminal window
pnpm exec drizzle-kit generate
git add src/schema.ts drizzle
git commit -m "Add schema migration"

The same directory input accepts handwritten SQL or drizzle-kit’s <timestamp>_<name>/migration.sql layout; see Drizzle migrations. Optional generation automation is documented separately in the Drizzle.Schema reference.

A Durable Object’s SQLite database belongs to one named instance, not to the Worker deployment. Cloudflare.SqlMigrations reads and normalizes a migration directory during construction/planning, then embeds its records in the Worker JavaScript. No ORM, SQL imports, migrations.js bundle, or migration environment variables are needed.

Use either flat files such as migrations/0001_init.sql or modern drizzle-kit directories such as drizzle/20260919000000_create_users/migration.sql. Commit the files before running Alchemy. Paths are relative to the command’s current working directory, not the source module.

Load the directory in the outer Effect:

import * as Cloudflare from "@alchemy.run/cloudflare";
import * as Effect from "effect/Effect";
export class Users extends Cloudflare.DurableObject<Users>()(
"Users",
Effect.gen(function* () {
const migrations = yield* Cloudflare.SqlMigrations("./migrations");
const state = yield* Cloudflare.DurableObjectState;
return Effect.gen(function* () {
yield* migrations.apply().pipe(Effect.orDie);
return {
listUsers: () =>
state.storage.sql.exec("SELECT id, name FROM users").pipe(
Effect.flatMap((cursor) => cursor.toArray()),
),
};
});
}),
) {}

migrations.apply() requires RuntimeContext and the current Durable Object state. Call it in the inner Effect before returning the instance’s API; it cannot run in the outer construction Effect. Its error channel includes MigrationError and MigrationHistoryConflictError. Using .pipe(Effect.orDie) here prevents activation if the schema cannot be initialized.

The object form selects a different history table:

const migrations = yield* Cloudflare.SqlMigrations({
dir: "./drizzle",
table: "app_migrations", // default: "__alchemy_migrations"
});

Each object applies pending files when it activates, not during deployment of the whole namespace. Each file’s SQL and history row commit together:

0001_create_users → commit SQL + history
0002_add_email → failure rolls back this file and prevents activation
next activation → skip 0001, retry 0002
__drizzle_migrations → copy matching history → __alchemy_migrations
missing match → MigrationHistoryConflictError

Adoption is one-way: keep the historical SQL files and use only Alchemy’s migrator afterward; the old table stays frozen. Changing migrationsTable on a migrations.js bundle does not convert its history.

For the legacy meta/_journal.json layout:

Terminal window
pnpm exec drizzle-kit up
git diff -- drizzle
git add drizzle
git commit -m "Upgrade Drizzle migration layout"

See the Drizzle guide or runnable example for the full setup.