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 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"
},
});

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_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/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.