| 1 | --- |
| 2 | title: Add Drizzle ORM |
| 3 | description: Deploy a Cloudflare Worker with Drizzle queries, Hyperdrive connections, and reviewed, committed migrations on Neon or PlanetScale. |
| 4 | --- |
| 5 | |
| 6 | import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; |
| 7 | |
| 8 | export const drizzleDeps = `drizzle-orm@1.0.0-rc.5-ab785fc @effect/sql-pg pg`; |
| 9 | export const drizzleDevDeps = `drizzle-kit@1.0.0-rc.5-ab785fc @types/pg`; |
| 10 | |
| 11 | This walkthrough connects a Worker to Postgres through Hyperdrive; [SQL](/sql) owns the portable client APIs. For Cloudflare's SQLite database, use [Drizzle on D1](/cloudflare/data/d1-drizzle). |
| 12 | |
| 13 | ## Install the client and generator |
| 14 | |
| 15 | <Tabs syncKey="pkgManager"> |
| 16 | <TabItem label="bun" icon="bun"> |
| 17 | <Code code={`bun add ${drizzleDeps}\nbun add -d ${drizzleDevDeps}`} lang="sh" /> |
| 18 | </TabItem> |
| 19 | <TabItem label="npm" icon="npm"> |
| 20 | <Code code={`npm install ${drizzleDeps}\nnpm install -D ${drizzleDevDeps}`} lang="sh" /> |
| 21 | </TabItem> |
| 22 | <TabItem label="pnpm" icon="pnpm"> |
| 23 | <Code code={`pnpm add ${drizzleDeps}\npnpm add -D ${drizzleDevDeps}`} lang="sh" /> |
| 24 | </TabItem> |
| 25 | <TabItem label="yarn" icon="seti:yarn"> |
| 26 | <Code code={`yarn add ${drizzleDeps}\nyarn add -D ${drizzleDevDeps}`} lang="sh" /> |
| 27 | </TabItem> |
| 28 | </Tabs> |
| 29 | |
| 30 | Keep the exact Drizzle prerelease pins together for the Effect integration; `drizzle-kit` is a development dependency. |
| 31 | |
| 32 | ## Define the schema |
| 33 | |
| 34 | ```typescript |
| 35 | // src/schema.ts |
| 36 | import { integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core"; |
| 37 | |
| 38 | export const Users = pgTable("users", { |
| 39 | id: serial("id").primaryKey(), |
| 40 | email: text("email").notNull().unique(), |
| 41 | name: text("name").notNull(), |
| 42 | createdAt: timestamp("created_at", { withTimezone: true }) |
| 43 | .notNull() |
| 44 | .defaultNow(), |
| 45 | }); |
| 46 | |
| 47 | export const Posts = pgTable("posts", { |
| 48 | id: serial("id").primaryKey(), |
| 49 | userId: integer("user_id") |
| 50 | .notNull() |
| 51 | .references(() => Users.id, { onDelete: "cascade" }), |
| 52 | title: text("title").notNull(), |
| 53 | body: text("body").notNull(), |
| 54 | createdAt: timestamp("created_at", { withTimezone: true }) |
| 55 | .notNull() |
| 56 | .defaultNow(), |
| 57 | }); |
| 58 | ``` |
| 59 | |
| 60 | This is an ordinary Postgres schema; it has no Worker-specific configuration. |
| 61 | |
| 62 | ## Configure migration generation |
| 63 | |
| 64 | ```typescript |
| 65 | // drizzle.config.ts |
| 66 | import { defineConfig } from "drizzle-kit"; |
| 67 | |
| 68 | export default defineConfig({ |
| 69 | schema: "./src/schema.ts", |
| 70 | out: "./migrations", |
| 71 | dialect: "postgresql", |
| 72 | }); |
| 73 | ``` |
| 74 | |
| 75 | The generator writes SQL and snapshots to `migrations`; it does not need database credentials. |
| 76 | |
| 77 | ## Generate and review migrations |
| 78 | |
| 79 | ```sh |
| 80 | bunx drizzle-kit generate |
| 81 | git diff -- src/schema.ts migrations |
| 82 | git status --short |
| 83 | git add src/schema.ts drizzle.config.ts migrations |
| 84 | git diff --cached |
| 85 | git commit -m "Add database migration" |
| 86 | ``` |
| 87 | |
| 88 | Generate whenever the schema changes, review the schema, SQL, and snapshots together, and commit them before deployment. The optional [`Drizzle.Schema` resource](/providers/drizzle/schema) is a separate integration, not part of this workflow. |
| 89 | |
| 90 | ## Configure the database |
| 91 | |
| 92 | Use [Neon's database configuration](/neon/guides/drizzle#feed-migrations-from-the-schema) to export `NeonDb` from `src/Db.ts` with `migrations: "./migrations"`. That guide owns the project, branch, and connection options; [PlanetScale configuration](/planetscale/guides/drizzle) is the alternative below. |
| 93 | |
| 94 | ## Add Hyperdrive |
| 95 | |
| 96 | ```typescript |
| 97 | // src/Hyperdrive.ts |
| 98 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 99 | import * as Effect from "effect/Effect"; |
| 100 | import { NeonDb } from "./Db.ts"; |
| 101 | |
| 102 | export const Hyperdrive = Effect.gen(function* () { |
| 103 | const { branch } = yield* NeonDb; |
| 104 | return yield* Cloudflare.Hyperdrive.Connection("app-hyperdrive", { |
| 105 | origin: branch.origin, |
| 106 | dev: branch.pooledOrigin, |
| 107 | }); |
| 108 | }); |
| 109 | ``` |
| 110 | |
| 111 | Hyperdrive pools the direct Neon origin; local development uses the pooled origin when it bypasses Hyperdrive. |
| 112 | |
| 113 | ## Open the connection with `Drizzle.Postgres` |
| 114 | |
| 115 | ```typescript |
| 116 | // src/Api.ts |
| 117 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 118 | import * as Drizzle from "alchemy/Drizzle/Postgres"; |
| 119 | import * as Effect from "effect/Effect"; |
| 120 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 121 | import { Hyperdrive } from "./Hyperdrive.ts"; |
| 122 | import { Users } from "./schema.ts"; |
| 123 | |
| 124 | export default class Api extends Cloudflare.Worker<Api>()( |
| 125 | "Api", |
| 126 | { |
| 127 | main: import.meta.url, |
| 128 | compatibility: { flags: ["nodejs_compat"] }, |
| 129 | }, |
| 130 | Effect.gen(function* () { |
| 131 | const hd = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive); |
| 132 | const db = yield* Drizzle.Postgres(hd.connectionString); |
| 133 | |
| 134 | return { |
| 135 | fetch: Effect.gen(function* () { |
| 136 | const users = yield* db.select().from(Users); |
| 137 | return yield* HttpServerResponse.json(users); |
| 138 | }), |
| 139 | }; |
| 140 | }).pipe(Effect.provide(Cloudflare.Hyperdrive.ConnectBinding)), |
| 141 | ) {} |
| 142 | ``` |
| 143 | |
| 144 | `nodejs_compat` enables the Node APIs used by `pg`, and `ConnectBinding` supplies the Worker's Hyperdrive binding. Queries yield directly as Effects and acquire their pool only within the request's [scope](/sql/effect-sql/lifecycle). |
| 145 | |
| 146 | ## Register the providers |
| 147 | |
| 148 | ```typescript |
| 149 | // alchemy.run.ts |
| 150 | import * as Alchemy from "alchemy"; |
| 151 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 152 | import * as Neon from "alchemy/Neon"; |
| 153 | import * as Effect from "effect/Effect"; |
| 154 | import * as Layer from "effect/Layer"; |
| 155 | import Api from "./src/Api.ts"; |
| 156 | |
| 157 | export default Alchemy.Stack( |
| 158 | "MyStack", |
| 159 | { |
| 160 | providers: Layer.mergeAll(Cloudflare.providers(), Neon.providers()), |
| 161 | state: Alchemy.localState(), |
| 162 | }, |
| 163 | Effect.gen(function* () { |
| 164 | const api = yield* Api; |
| 165 | return { url: api.url }; |
| 166 | }), |
| 167 | ); |
| 168 | ``` |
| 169 | |
| 170 | Only the runtime and database providers are needed; committed migration files do not require `Drizzle.providers()`. |
| 171 | |
| 172 | ## Deploy |
| 173 | |
| 174 | ```sh |
| 175 | bun alchemy deploy |
| 176 | ``` |
| 177 | |
| 178 | The configured branch applies pending committed SQL before the Worker serves queries; deployment does not generate new migrations. Keep an existing `drizzle-kit migrate` workflow unless you deliberately choose Alchemy-managed application, as described in [migration ownership](/sql/drizzle/migrations). |
| 179 | |
| 180 | ## Define relations for typed `db.query` |
| 181 | |
| 182 | ```diff lang="typescript" |
| 183 | // src/schema.ts |
| 184 | +import { defineRelations } from "drizzle-orm"; |
| 185 | |
| 186 | +export const relations = defineRelations({ Users, Posts }, (t) => ({ |
| 187 | + Users: { posts: t.many.Posts() }, |
| 188 | + Posts: { |
| 189 | + user: t.one.Users({ |
| 190 | + from: t.Posts.userId, |
| 191 | + to: t.Users.id, |
| 192 | + }), |
| 193 | + }, |
| 194 | +})); |
| 195 | ``` |
| 196 | |
| 197 | Append the relations after the table definitions; this metadata enables the relational query API without changing migration SQL. |
| 198 | |
| 199 | ## Pass `relations` to `Drizzle.Postgres` |
| 200 | |
| 201 | ```diff lang="typescript" |
| 202 | // src/Api.ts |
| 203 | -import { Users } from "./schema.ts"; |
| 204 | +import { relations, Users } from "./schema.ts"; |
| 205 | |
| 206 | -const db = yield* Drizzle.Postgres(hd.connectionString); |
| 207 | +const db = yield* Drizzle.Postgres(hd.connectionString, { relations }); |
| 208 | ``` |
| 209 | |
| 210 | The returned client now exposes `db.query.Users` and `db.query.Posts` with typed relation names. |
| 211 | |
| 212 | ## Query related rows |
| 213 | |
| 214 | ```diff lang="typescript" |
| 215 | // src/Api.ts |
| 216 | -const users = yield* db.select().from(Users); |
| 217 | -return yield* HttpServerResponse.json(users); |
| 218 | +const user = yield* db.query.Users.findFirst({ |
| 219 | + where: { id: 1 }, |
| 220 | + with: { posts: true }, |
| 221 | +}); |
| 222 | +return yield* HttpServerResponse.json({ user }); |
| 223 | ``` |
| 224 | |
| 225 | `with: { posts: true }` includes the related posts in the inferred result type. |
| 226 | |
| 227 | ## Iterate on the schema |
| 228 | |
| 229 | ```sh |
| 230 | bunx drizzle-kit generate |
| 231 | git add src/schema.ts migrations |
| 232 | git diff --cached |
| 233 | git commit -m "Update database schema" |
| 234 | bun alchemy deploy |
| 235 | ``` |
| 236 | |
| 237 | Review each generated change before committing and deploy only those committed files. Reverting a TypeScript schema does not undo applied SQL; author and review a new migration or follow your database's restore procedure. |
| 238 | |
| 239 | ## Use PlanetScale instead |
| 240 | |
| 241 | ```diff lang="typescript" |
| 242 | // alchemy.run.ts |
| 243 | -import * as Neon from "alchemy/Neon"; |
| 244 | +import * as Planetscale from "alchemy/Planetscale"; |
| 245 | |
| 246 | - providers: Layer.mergeAll(Cloudflare.providers(), Neon.providers()), |
| 247 | + providers: Layer.mergeAll(Cloudflare.providers(), Planetscale.providers()), |
| 248 | ``` |
| 249 | |
| 250 | Use the [PlanetScale guide](/planetscale/guides/drizzle) to export `PlanetscaleDb` with committed migrations; it owns both Postgres role and MySQL password configuration. |
| 251 | |
| 252 | ## Connect a PlanetScale Postgres role |
| 253 | |
| 254 | ```typescript |
| 255 | // src/Hyperdrive.ts |
| 256 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 257 | import * as Effect from "effect/Effect"; |
| 258 | import { PlanetscaleDb } from "./Db.ts"; |
| 259 | |
| 260 | export const Hyperdrive = Effect.gen(function* () { |
| 261 | const { role } = yield* PlanetscaleDb; |
| 262 | return yield* Cloudflare.Hyperdrive.Connection("app-hyperdrive", { |
| 263 | origin: role.origin, |
| 264 | dev: role.pooledOrigin, |
| 265 | caching: { disabled: true }, |
| 266 | }); |
| 267 | }); |
| 268 | ``` |
| 269 | |
| 270 | The Worker, Postgres schema, and queries remain unchanged. |
| 271 | |
| 272 | ## Connect a PlanetScale MySQL password |
| 273 | |
| 274 | ```typescript |
| 275 | // Inside the Hyperdrive effect, replacing the Postgres role configuration: |
| 276 | const { password } = yield* PlanetscaleDb; |
| 277 | return yield* Cloudflare.Hyperdrive.Connection("app-hyperdrive", { |
| 278 | origin: password.origin, |
| 279 | caching: { disabled: true }, |
| 280 | }); |
| 281 | ``` |
| 282 | |
| 283 | Select the MySQL resource variant in the [PlanetScale guide](/planetscale/guides/drizzle#mysql-apply-and-connect); there is no pooled password origin. |
| 284 | |
| 285 | ## Select the MySQL client |
| 286 | |
| 287 | ```sh |
| 288 | bun add @effect/sql-mysql2 mysql2 |
| 289 | ``` |
| 290 | |
| 291 | ```diff lang="typescript" |
| 292 | // src/Api.ts |
| 293 | -import * as Drizzle from "alchemy/Drizzle/Postgres"; |
| 294 | +import * as Drizzle from "alchemy/Drizzle/MySQL"; |
| 295 | |
| 296 | -const db = yield* Drizzle.Postgres(hd.connectionString, { relations }); |
| 297 | +const db = yield* Drizzle.MySQL(hd.connectionString, { relations }); |
| 298 | ``` |
| 299 | |
| 300 | Use the [MySQL schema and generator configuration](/sql/drizzle/mysql), then generate, review, and commit MySQL migrations before deploying. The client uses [Workers-safe defaults](/sql/effect-sql/mysql#workers-defaults); MySQL-specific query differences stay in the portable client guide. |
| 301 | |
| 302 | ## Where to from here |
| 303 | |
| 304 | Use [Neon](/neon) or [PlanetScale](/planetscale) for database service behavior and [SQL databases](/sql/databases) to discover other providers. [Branch from a shared database](/cloudflare/data/branch-from-shared-database) covers the Worker deployment pattern for preview environments. |