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.
Write the migrations
Section titled “Write the migrations”-- migrations/0001_init.sqlCREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE);-- migrations/0002_posts.sqlCREATE TABLE posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), title TEXT NOT NULL);Wire the directory in
Section titled “Wire the directory in”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.
How files are applied
Section titled “How files are applied”- The directory is scanned recursively for
.sqlfiles. - 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.
The tracking table
Section titled “The tracking table”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, so renaming an applied file is a schema
change, not a cosmetic one.
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" },});Adopting an existing database
Section titled “Adopting an existing database”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; an orphaned
row fails the deploy with
MigrationHistoryConflictError— it means migrations were applied that your checkout doesn’t have. - A failed Prisma migration (
finished_atNULL) blocks adoption until repaired withprisma 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:
pnpm exec drizzle-kit generategit add src/schema.ts drizzlegit 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.
Durable Object migrations
Section titled “Durable Object migrations”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/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"});Per-instance application
Section titled “Per-instance application”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 + history0002_add_email → failure rolls back this file and prevents activationnext activation → skip 0001, retry 0002Adopting Drizzle history
Section titled “Adopting Drizzle history”__drizzle_migrations → copy matching history → __alchemy_migrations missing match → MigrationHistoryConflictErrorAdoption 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.
Upgrade legacy Drizzle files
Section titled “Upgrade legacy Drizzle files”For the legacy meta/_journal.json layout:
pnpm exec drizzle-kit upgit diff -- drizzlegit add drizzlegit commit -m "Upgrade Drizzle migration layout"See the Drizzle guide or runnable example for the full setup.
Where next
Section titled “Where next”- Postgres / MySQL / D1 — query the migrated database.
- Drizzle migrations — generate the files from a schema module instead of writing them by hand.
- Branch from a shared database — per-stage databases built on the same contract.