Skip to content

Postgres

Drizzle on Postgres, end to end: a schema module, a Drizzle.Schema resource that generates migration SQL on deploy, a database branch that applies it, and a Worker that queries through Drizzle.Postgres over Hyperdrive.

Install the toolchain — all optional peers of alchemy:

Terminal window
bun add drizzle-orm @effect/sql-pg pg
bun add -D drizzle-kit

Drizzle schemas are plain TypeScript modules using the pg-core column builders:

src/schema.ts
import { defineRelations } from "drizzle-orm";
import { integer, pgTable, serial, text } from "drizzle-orm/pg-core";
export const Users = pgTable("users", {
id: serial("id").primaryKey(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
});
export const Posts = pgTable("posts", {
id: serial("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => Users.id, { onDelete: "cascade" }),
title: text("title").notNull(),
});
export const relations = defineRelations({ Users, Posts }, (t) => ({
Users: { posts: t.many.Posts() },
Posts: {
user: t.one.Users({ from: t.Posts.userId, to: t.Users.id }),
},
}));

Drizzle.Schema diffs the schema module on each deploy and writes pending migration SQL to out. Passing schema.out as the database’s migrationsDir creates the dependency edge: generate first, apply second, in one alchemy deploy. On Neon:

src/db.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Drizzle from "alchemy/Drizzle";
import * as Neon from "alchemy/Neon";
import * as Effect from "effect/Effect";
export const Db = Effect.gen(function* () {
const schema = yield* Drizzle.Schema("app-schema", {
schema: "./src/schema.ts",
out: "./migrations",
});
const project = yield* Neon.Project("app-db");
const branch = yield* Neon.Branch("app-branch", {
project,
migrationsDir: schema.out,
});
return branch;
});
export const Hyperdrive = Effect.gen(function* () {
const branch = yield* Db;
return yield* Cloudflare.Hyperdrive.Connection("app-hyperdrive", {
origin: branch.origin,
});
});

Planetscale.PostgresBranch takes the same migrationsDir wiring. Register Drizzle.providers() alongside your cloud providers in the Stack. Migrations covers what the schema resource does — and does not — decide on your behalf.

Hyperdrive pools connections at the edge; Drizzle.Postgres takes its connection string:

src/api.ts
import * as Cloudflare from "alchemy/Cloudflare";
import * as Drizzle from "alchemy/Drizzle";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import { Hyperdrive } from "./db.ts";
import { relations, Users } from "./schema.ts";
export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const conn = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive);
const db = yield* Drizzle.Postgres(conn.connectionString, { relations });
return {
fetch: Effect.gen(function* () {
const users = yield* db.select().from(Users);
return yield* HttpServerResponse.json({ users });
}),
};
}).pipe(Effect.provide(Cloudflare.Hyperdrive.ConnectBinding)),
) {}

Nothing connects at init — the pool opens on the first query of an event, is reused for every query in that event, and closes when the event settles (see Connection lifecycle). Plan and deploy never open a connection.

Every builder yields directly, with SqlError in the typed error channel:

const [created] = yield* db
.insert(Users)
.values({ name, email })
.returning();
const [removed] = yield* db
.delete(Users)
.where(eq(Users.id, id))
.returning();

Because relations was passed to Drizzle.Postgres, the typed db.query.* API is available:

const user = yield* db.query.Users.findFirst({
where: { id },
with: { posts: true },
});