Skip to content

Migrations

Drizzle.Schema puts drizzle-kit generate inside the deploy graph: on each deploy it diffs your schema module against the latest checked-in snapshot, writes any pending migration SQL to out, and the database resource downstream applies it via migrations — in that order, because schema.out is an Output the database depends on.

const schema = yield* Drizzle.Schema("app-schema", {
schema: "./src/schema.ts",
out: "./migrations",
dialect: "sqlite",
});
const db = yield* Cloudflare.D1.Database("app-db", {
migrations: schema,
});
  • No drift — the schema matches the latest snapshot: nothing is generated, the resource is a noop, and nothing cascades into the database resource.
  • Unambiguous drift — a new table, a new column: the migration SQL is generated automatically and applied in the same deploy.
  • Ambiguous drift — a change drizzle-kit cannot decide alone (did you rename email to contact, or drop one column and add another? a change that would lose data): it needs your answer.
    • In a terminal, alchemy deploy surfaces drizzle-kit’s interactive prompts and you answer inline.
    • Non-interactively (CI, --yes pipelines), the deploy fails with drizzle-kit’s report and instructions: run drizzle-kit generate yourself, answer the prompts, commit the generated files, and redeploy. The generated SQL is applied to a real database in the same deploy — alchemy never guesses on a destructive choice.

Generated files land under out as ordered migration directories containing migration.sql and snapshot.json. Commit them — they are source code, shared with every other environment that deploys the same app.

Drizzle.Schema is optional. If you prefer to run drizzle-kit generate manually and commit the output, point migrations at the checked-in directory — the database resource only sees .sql files:

const db = yield* Cloudflare.D1.Database("app-db", {
migrations: "./drizzle", // drizzle-kit's default out
});

A Durable Object’s SQLite database is per-instance, so its migrations run inside the object at runtime rather than from the deploy graph (see drizzle’s Durable Objects guide). Generate them with drizzle-kit’s durable-sqlite driver:

drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
driver: "durable-sqlite",
schema: "./src/schema.ts",
out: "./drizzle",
});

Define the schema — tables plus defineRelations for typed relational queries:

schema.ts
import { defineRelations } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
});
export const posts = sqliteTable("posts", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id),
title: text("title").notNull(),
});
export const relations = defineRelations({ users, posts }, (t) => ({
users: { posts: t.many.posts() },
posts: { author: t.one.users({ from: t.posts.userId, to: t.users.id }) },
}));

drizzle-kit generate writes the migration directories plus a migrations.js bundle that imports each migration’s .sql file. The alchemy bundler loads bare .sql imports as text modules (the same way it handles .txt and .html), so the generated bundle imports directly into your Durable Object. Commit it and hand it to Drizzle.DurableObject, along with the relations:

import * as Drizzle from "alchemy/Drizzle/Cloudflare";
import migrations from "./drizzle/migrations.js";
import { posts, relations, users } from "./schema.ts";
export class Users extends Cloudflare.DurableObject<Users>()(
"Users",
Effect.gen(function* () {
return Effect.gen(function* () {
const db = yield* Drizzle.DurableObject({ migrations, relations });
return {
addUser: (name: string) => db.insert(users).values({ name }),
listUsers: () => db.select().from(users),
listUsersWithPosts: () =>
db.query.users.findMany({ with: { posts: true } }),
};
});
}),
) {}

Drizzle.DurableObject (from alchemy/Drizzle/Cloudflare) opens drizzle over the instance’s own SQLite storage using the drizzle-orm/effect-sqlite-do integration — the same effect-native query surface as Drizzle.D1 and Drizzle.Postgres. It lives in the object’s inner Effect — the instance init, which runs only in the deployed object, before any request reaches its methods. The config passes the driver’s options through — the relations make db.query.users.findMany({ with: { posts: true } }) fully typed.

Every query carries a typed error channel, so failures are handled per operation with Effect.catchTag:

db.insert(users).values({ name }).pipe(
Effect.catchTag("EffectDrizzleQueryError", (error) =>
Effect.succeed(undefined),
),
);

EffectDrizzleQueryError carries the query, its params, and the underlying cause. Transactions add the effect-sql SqlError to the union, and the migrator can fail with MigratorInitError.

Already ran drizzle-kit migrate against the database before Alchemy? No baselining needed: on the first deploy Alchemy copies the applied history out of __drizzle_migrations (the drizzle schema on Postgres) into its own __alchemy_migrations table — hashes carry over verbatim, since both record sha256 of migration.sql — and only genuinely pending migrations run. The drizzle table is left frozen, never written or dropped.

It’s a one-way move: from then on Alchemy owns the bookkeeping, so retire drizzle-kit migrate from your workflow (drizzle-kit generate is subsumed by Drizzle.Schema). See adopting an existing database for the shared mechanics and guard rails.

The provider’s delete operation is a deliberate no-op. Removing the resource (or destroying the stack) never wipes the migrations directory — migration files are source code you commit, not cloud state to tear down.

dialect selects drizzle-kit’s target: "postgres" (the default) for Neon / PlanetScale / Fly / Hyperdrive-fronted Postgres, "mysql" for PlanetScale MySQL, "sqlite" for D1.