Drizzle
Switch an existing Drizzle app to Prisma 8: infer a contract from your database, sign it, and replace Drizzle queries route by route.
Introduction
This guide shows you how to migrate an application from Drizzle to Prisma 8. The sample app is a Next.js project with API routes for users, profiles, posts and categories. Posts and categories are connected through a posts_to_categories join table, the schema was pushed to a local PostgreSQL database with drizzle-kit push, and the routes read and write through drizzle-orm.
Prisma 8 reads the schema that Drizzle already created, so you do not rebuild the database. You add Prisma 8 to the project, infer a contract from the live tables, sign the database, and then replace Drizzle queries one at a time. The routes keep working during the switch, so you can move as slowly or as quickly as you like.
Every command and response below was run end to end against the sample app and a local PostgreSQL 17 database.
Prisma 8 is the current release of Prisma ORM. Prisma 7 remains fully supported; the Prisma 7 version of this guide is at /guides/v7/switch-to-prisma-orm/from-drizzle.
Prerequisites
- Node.js 24 or later
- A Drizzle project that talks to a PostgreSQL database, and its connection string in
DATABASE_URL - Basic familiarity with Drizzle and Next.js
Use with your agent
To delegate this guide to your coding agent, copy the prompt below and hand it over:
Overview of the migration process
The steps are the same for any app that uses Drizzle, whether it serves a REST API, a GraphQL API or server-rendered pages:
- Add Prisma 8 to the project with
orm init. - Infer a contract from the live database.
- Review the inferred contract.
- Emit the contract and sign the database.
- Replace Drizzle queries with Prisma 8 queries.
Prisma 8 supports incremental adoption. Both ORMs can talk to the same database while you migrate, so you can move one route at a time and ship in between.
If you migrated to Prisma 7 before, you will notice what is missing: there is no prisma init, no db pull, no baseline migration, no prisma generate, and no driver adapter package. The contract you emit is checked in, the runtime is typed from it directly, and db sign replaces the baseline migration.
The sample app
The Drizzle schema declares a role enum, four tables and an explicit join table:
import { relations } from "drizzle-orm";
import {
boolean,
integer,
pgEnum,
pgTable,
primaryKey,
serial,
text,
timestamp,
} from "drizzle-orm/pg-core";
export const roleEnum = pgEnum("role", ["USER", "ADMIN"]);
export const users = pgTable("users", {
id: serial("id").primaryKey(),
email: text("email").notNull().unique(),
name: text("name"),
role: roleEnum("role").default("USER").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const profiles = pgTable("profiles", {
id: serial("id").primaryKey(),
bio: text("bio"),
userId: integer("user_id")
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
});
export const posts = pgTable("posts", {
id: serial("id").primaryKey(),
title: text("title").notNull(),
content: text("content"),
published: boolean("published").default(false).notNull(),
authorId: integer("author_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const categories = pgTable("categories", {
id: serial("id").primaryKey(),
name: text("name").notNull().unique(),
});
export const postsToCategories = pgTable(
"posts_to_categories",
{
postId: integer("post_id")
.notNull()
.references(() => posts.id, { onDelete: "cascade" }),
categoryId: integer("category_id")
.notNull()
.references(() => categories.id, { onDelete: "cascade" }),
},
(t) => [primaryKey({ columns: [t.postId, t.categoryId] })],
);
export const usersRelations = relations(users, ({ one, many }) => ({
profile: one(profiles, { fields: [users.id], references: [profiles.userId] }),
posts: many(posts),
}));
export const profilesRelations = relations(profiles, ({ one }) => ({
user: one(users, { fields: [profiles.userId], references: [users.id] }),
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
postsToCategories: many(postsToCategories),
}));
export const categoriesRelations = relations(categories, ({ many }) => ({
postsToCategories: many(postsToCategories),
}));
export const postsToCategoriesRelations = relations(postsToCategories, ({ one }) => ({
post: one(posts, { fields: [postsToCategories.postId], references: [posts.id] }),
category: one(categories, {
fields: [postsToCategories.categoryId],
references: [categories.id],
}),
}));The Drizzle client reads the same DATABASE_URL that Prisma 8 will use:
import "dotenv/config";
import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "./schema";
export const db = drizzle(process.env.DATABASE_URL!, { schema });The API routes live under src/app/api/: GET and POST /api/users, GET and POST /api/posts, PATCH and DELETE /api/posts/[id], and GET /api/categories. Their Drizzle code is shown next to its Prisma 8 replacement in step 5.
1. Add Prisma 8 to the project
Run orm init in the root of the Drizzle project. It preselects PostgreSQL and adds Prisma 8 to the app you already have; it does not scaffold a new project.
bunx prisma@latest orm init --target postgresChoose PSL as the authoring style and keep the default schema path, src/prisma/contract.prisma. The command installs the runtime and CLI packages, writes the Prisma 8 files, and emits a starter contract:
✔ npm add @prisma/orm-postgres dotenv
✔ npm add -D prisma@latest
✔ npm add -D @prisma/cli-engine@0.3.0
✔ Emit the contract
│ target: postgres
│ authoring: psl
│ schema: src/prisma/contract.prisma
written
├─ src/prisma/contract.prisma
├─ prisma.config.ts
├─ src/prisma/db.ts
├─ prisma-8.md
├─ .env.example
├─ tsconfig.json
├─ .gitignore
├─ .gitattributes
└─ package.json
✔ Done. Open prisma-8.md to get started.
→ Set DATABASE_URL in your environment (export it or add it to .env)
→ Edit your schema at src/prisma/contract.prisma, then emit againThree files matter for the migration. prisma.config.ts tells the CLI where the contract lives and how to reach the database; it loads .env through dotenv/config, so the DATABASE_URL your Drizzle config already uses works unchanged:
import 'dotenv/config';
import { definePrismaConfig } from '@prisma/cli-engine';
import { defineConfig as ormConfig } from '@prisma/orm-postgres/config';
export default definePrismaConfig({
orm: ormConfig({
contract: "./src/prisma/contract.prisma",
db: {
connection: process.env['DATABASE_URL']!,
},
}),
});src/prisma/db.ts is the client your routes will import. It talks to PostgreSQL directly, so there is no driver adapter and no generated client package:
import 'dotenv/config';
import postgres from '@prisma/orm-postgres/runtime';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
export const db = postgres<Contract>({
contractJson,
url: process.env['DATABASE_URL']!,
});src/prisma/contract.prisma holds a placeholder User and Post model. You replace it in the next step.
orm init also sets "type": "module" in package.json and adds "types": ["node"] plus "module": "preserve" to tsconfig.json. Next.js runs, builds and type-checks with those settings, so leave them in place.
2. Infer a contract from your database
Read the schema that Drizzle pushed into a PSL contract. --output points at the file orm init created, and the command overwrites the placeholder:
bunx prisma@latest contract infer --output ./src/prisma/contract.prisma✔ Connecting to database...
✔ Introspecting database schema...
Overwriting existing file: src/prisma/contract.prisma
│ database: postgres://****@localhost:5432/drizzle_app
✔ Contract written to src/prisma/contract.prismaFor the sample app, the inferred contract looks like this:
// use prisma-8
// Contract inferred from the live database schema. Edit as needed, then run `prisma contract emit`.
namespace public {
model Categories {
id Int @id(map: "categories_pkey") @default(autoincrement())
name String @unique(map: "categories_name_unique")
postsToCategories PostsToCategories[]
@@map("categories")
}
model Users {
id Int @id(map: "users_pkey") @default(autoincrement())
email String @unique(map: "users_email_unique")
name String?
role pg.enum(Role) @default("USER")
createdAt Timestamptz @default(now()) @map("created_at")
posts Posts[]
profiles Profiles?
@@map("users")
}
model Posts {
id Int @id(map: "posts_pkey") @default(autoincrement())
title String
content String?
published Boolean @default(false)
authorId Int @map("author_id")
createdAt Timestamptz @default(now()) @map("created_at")
postsToCategories PostsToCategories[]
author Users @relation(fields: [authorId], references: [id], onDelete: Cascade, map: "posts_author_id_users_id_fk", index: false)
@@map("posts")
}
model PostsToCategories {
postId Int @map("post_id")
categoryId Int @map("category_id")
category Categories @relation(fields: [categoryId], references: [id], onDelete: Cascade, map: "posts_to_categories_category_id_categories_id_fk", index: false)
post Posts @relation(fields: [postId], references: [id], onDelete: Cascade, map: "posts_to_categories_post_id_posts_id_fk", index: false)
@@id([postId, categoryId], map: "posts_to_categories_post_id_category_id_pk")
@@map("posts_to_categories")
}
model Profiles {
id Int @id(map: "profiles_pkey") @default(autoincrement())
bio String?
userId Int @unique(map: "profiles_user_id_unique") @map("user_id")
user Users @relation(fields: [userId], references: [id], onDelete: Cascade, map: "profiles_user_id_users_id_fk")
@@map("profiles")
}
native_enum Role {
USER = "USER"
ADMIN = "ADMIN"
@@map("role")
}
}Here is how each part of the Drizzle schema came through:
- Namespace. Every model sits inside
namespace public { ... }, the PostgreSQL schema Drizzle pushed to. This is why queries are written asdb.orm.public.Userlater. - Names. Model names are PascalCase versions of the table names, so they are plural (
Users,Posts);@@mapkeeps the real table name. Snake case columns become camelCase fields with@map, socreated_atiscreatedAt. - Constraints and defaults. Primary keys, unique constraints and foreign keys keep their database names through
map:.serialcolumns becomeInt @id @default(autoincrement()). Thedefault(false)anddefaultNow()calls come back as@default(false)and@default(now()). - Enum. The
pgEnum("role", ...)type is anative_enum Roleblock with@@map("role"), and the column uses it aspg.enum(Role) @default("USER"). The TypeScript type of the field is'USER' | 'ADMIN'. - Timestamps.
timestamp(..., { withTimezone: true })columns are typedTimestamptz. You change this in the next step. - Relations. Each
.references(...)is an@relation(...)on the side that holds the foreign key, withonDelete: Cascadecarried over.index: falserecords that Drizzle created no separate index for the foreign key. The list side (posts Posts[]) is derived for you; therelations(...)helpers inschema.tshave no equivalent because the contract already knows both directions. - One-to-one. The unique
user_idonprofilescame through asprofiles Profiles?onUsersanduser UsersonProfiles. - Many-to-many. The join table is an explicit
PostsToCategoriesmodel with a composite@@id, andPostsandCategorieseach hold a list of the join model, not of each other. Prisma 8 has no implicit many-to-many; the many-to-many section below shows how to add the direct traversal.
3. Review the contract
Inference is a starting point. Make three edits before you emit.
3.1. Singularize the model names
Rename the models to singular PascalCase and keep every @@map, so the table names do not change: Categories becomes Category, Users becomes User, Posts becomes Post, Profiles becomes Profile, and PostsToCategories becomes PostToCategory. Update the relation field types to match, and rename the profiles field on User to profile.
3.2. Use string timestamps
Change both Timestamptz fields to TimestamptzString. Timestamptz reads and writes through the Temporal API, which Node.js does not ship yet; with the inferred type, the first query that touches a timestamp fails:
Error [StructuredError]: Codec 'pg/timestamptz-temporal@1' cannot decode a value because this runtime has no global Temporal implementation.
code: 'RUNTIME.TEMPORAL_UNAVAILABLE',
fix: "Run on a runtime with Temporal available, install a Temporal polyfill before creating the client, or author the column with its *String type to read and write PostgreSQL's own text instead."TimestamptzString keeps the timestamptz column exactly as it is and hands your code the value as a string. Drizzle gave you a Date here, so a JSON response changes from "2026-09-10T15:32:51.298Z" to "2026-09-10 21:32:51.298482+06". If a client depends on the ISO form, convert with new Date(value).toISOString() at the edge.
3.3. Add direct many-to-many fields
Add categories Category[] to Post and posts Post[] to Category, next to the existing postsToCategories lists. The join model stays, and the two new fields resolve through it, so Post can include its categories directly. This is a contract-only change: the storage hash stays the same and no migration is needed.
After the three edits the contract looks like this:
// use prisma-8
namespace public {
model Category {
id Int @id(map: "categories_pkey") @default(autoincrement())
name String @unique(map: "categories_name_unique")
postsToCategories PostToCategory[]
posts Post[]
@@map("categories")
}
model User {
id Int @id(map: "users_pkey") @default(autoincrement())
email String @unique(map: "users_email_unique")
name String?
role pg.enum(Role) @default("USER")
createdAt TimestamptzString @default(now()) @map("created_at")
posts Post[]
profile Profile?
@@map("users")
}
model Post {
id Int @id(map: "posts_pkey") @default(autoincrement())
title String
content String?
published Boolean @default(false)
authorId Int @map("author_id")
createdAt TimestamptzString @default(now()) @map("created_at")
postsToCategories PostToCategory[]
categories Category[]
author User @relation(fields: [authorId], references: [id], onDelete: Cascade, map: "posts_author_id_users_id_fk", index: false)
@@map("posts")
}
model PostToCategory {
postId Int @map("post_id")
categoryId Int @map("category_id")
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade, map: "posts_to_categories_category_id_categories_id_fk", index: false)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade, map: "posts_to_categories_post_id_posts_id_fk", index: false)
@@id([postId, categoryId], map: "posts_to_categories_post_id_category_id_pk")
@@map("posts_to_categories")
}
model Profile {
id Int @id(map: "profiles_pkey") @default(autoincrement())
bio String?
userId Int @unique(map: "profiles_user_id_unique") @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade, map: "profiles_user_id_users_id_fk")
@@map("profiles")
}
native_enum Role {
USER = "USER"
ADMIN = "ADMIN"
@@map("role")
}
}4. Emit the contract and sign the database
Emit the contract to refresh contract.json and contract.d.ts, the two files the runtime and the CLI read:
bunx prisma@latest contract emit✔ Emitted contract.json and contract.d.ts
storageHash: 7cbfecc26820132f373d82c4e3ba43ce2ec6d4a4c3f6da73a58e5a31d461d09c
profileHash: 3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2Then sign the database. This records that the live schema matches the emitted contract, and it is the Prisma 8 replacement for the baseline migration you would have created with Prisma 7:
bunx prisma@latest db sign✔ Verifying database schema...
✔ Signing database...
│ contract: src/prisma/contract.json
│ database: postgres://****@localhost:5432/drizzle_app
✔ Database signed
from: none
to: 7cbfecc26820132f373d82c4e3ba43ce2ec6d4a4c3f6da73a58e5a31d461d09cdb sign verifies the schema before it writes the signature; if a table or column in the contract does not match the database, it exits with code 4 and signs nothing. Confirm the result with db verify:
bunx prisma@latest db verify✔ Database marker and schema match contract
storageHash: 7cbfecc26820132f373d82c4e3ba43ce2ec6d4a4c3f6da73a58e5a31d461d09c
profileHash: 3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2From here on, schema changes go through the contract: edit contract.prisma, run contract emit, then db update for a direct development update or migration plan and db migrate for a checked-in migration. You can delete drizzle.config.ts and stop running drizzle-kit once you no longer want it to own the schema.
5. Replace your Drizzle queries
Every route imports the same client, db from src/prisma/db.ts, and reaches models as db.orm.public.<Model>. Migrate one route, run it, then move to the next. For the full query surface, see Reading data, Writing data and the ORM client reference.
5.1. Reads with relations
The users route loads each user with their profile and posts. In Drizzle, that is db.query.users.findMany with a with clause:
import { NextResponse } from "next/server";
import { db } from "@/db/drizzle";
export async function GET() {
const result = await db.query.users.findMany({
with: { profile: true, posts: true },
orderBy: (u, { asc }) => [asc(u.id)],
});
return NextResponse.json(result);
}With Prisma 8, each relation is an .include(...), and sorting is .orderBy(...) with a lambda over the fields:
import { NextResponse } from "next/server";
import { db } from "@/prisma/db";
export async function GET() {
const result = await db.orm.public.User
.include("profile")
.include("posts")
.orderBy((u) => u.id.asc())
.all();
return NextResponse.json(result);
}The response has the same shape as before; the only visible change is the timestamp format from step 3.2:
curl http://localhost:3000/api/users[
{
"createdAt": "2026-09-10 21:32:51.298482+06",
"email": "alice@prisma.io",
"id": 1,
"name": "Alice",
"role": "ADMIN",
"profile": { "bio": "Writes about databases", "id": 1, "userId": 1 },
"posts": [
{ "authorId": 1, "content": "First post", "createdAt": "2026-09-10 21:32:51.929589+06", "id": 1, "published": true, "title": "Hello Drizzle" }
]
},
{
"createdAt": "2026-09-10 21:32:51.298482+06",
"email": "bob@prisma.io",
"id": 2,
"name": "Bob",
"role": "USER",
"profile": null,
"posts": [
{ "authorId": 2, "content": null, "createdAt": "2026-09-10 21:32:51.929589+06", "id": 2, "published": false, "title": "Typed queries" }
]
}
]5.2. Reads through a join table
The posts route loads the author and the categories. Drizzle traverses the join table explicitly, so every post carries postsToCategories entries with a nested category:
import { NextResponse } from "next/server";
import { db } from "@/db/drizzle";
export async function GET() {
const result = await db.query.posts.findMany({
with: {
author: true,
postsToCategories: { with: { category: true } },
},
orderBy: (p, { asc }) => [asc(p.id)],
});
return NextResponse.json(result);
}With the categories field you added in step 3.3, Prisma 8 includes the categories directly and drops the join records from the result:
import { NextResponse } from "next/server";
import { db } from "@/prisma/db";
export async function GET() {
const result = await db.orm.public.Post
.include("author")
.include("categories")
.orderBy((p) => p.id.asc())
.all();
return NextResponse.json(result);
}curl http://localhost:3000/api/posts[
{
"authorId": 1,
"content": "First post",
"createdAt": "2026-09-10 21:32:51.929589+06",
"id": 1,
"published": true,
"title": "Hello Drizzle",
"author": { "createdAt": "2026-09-10 21:32:51.298482+06", "email": "alice@prisma.io", "id": 1, "name": "Alice", "role": "ADMIN" },
"categories": [
{ "id": 1, "name": "databases" },
{ "id": 2, "name": "typescript" }
]
},
{
"authorId": 2,
"content": null,
"createdAt": "2026-09-10 21:32:51.929589+06",
"id": 2,
"published": false,
"title": "Typed queries",
"author": { "createdAt": "2026-09-10 21:32:51.298482+06", "email": "bob@prisma.io", "id": 2, "name": "Bob", "role": "USER" },
"categories": [{ "id": 2, "name": "typescript" }]
}
]If you want the join records in the response, keep the shape Drizzle produced with a nested include: .include("postsToCategories", (link) => link.include("category")).
5.3. Creates
Creating a post with Drizzle is an insert with .returning() followed by a second insert into the join table:
import { NextResponse } from "next/server";
import { db } from "@/db/drizzle";
import { posts, postsToCategories } from "@/db/schema";
export async function POST(request: Request) {
const body = (await request.json()) as {
title: string;
content?: string;
authorId: number;
categoryIds?: number[];
};
const [post] = await db
.insert(posts)
.values({ title: body.title, content: body.content, authorId: body.authorId })
.returning();
if (body.categoryIds?.length) {
await db
.insert(postsToCategories)
.values(body.categoryIds.map((categoryId) => ({ postId: post.id, categoryId })));
}
return NextResponse.json(post, { status: 201 });
}.create(...) returns the inserted row, defaults included, and a nested connect writes the join rows in the same transaction:
import { NextResponse } from "next/server";
import { db } from "@/prisma/db";
export async function POST(request: Request) {
const body = (await request.json()) as {
title: string;
content?: string;
authorId: number;
categoryIds?: number[];
};
const post = await db.orm.public.Post.create({
title: body.title,
content: body.content ?? null,
authorId: body.authorId,
categories: (c) => c.connect((body.categoryIds ?? []).map((id) => ({ id }))),
});
return NextResponse.json(post, { status: 201 });
}curl -X POST http://localhost:3000/api/posts \
-H "content-type: application/json" \
-d '{"title":"Drizzle to Prisma","content":"Migration notes","authorId":1,"categoryIds":[1,2]}'{ "authorId": 1, "content": "Migration notes", "createdAt": "2026-09-10 21:58:28.179323+06", "id": 5, "published": false, "title": "Drizzle to Prisma" }Optional fields are explicit: pass null for a nullable column you do not set, as content shows. The users route follows the same pattern with two plain creates, User.create(...) and then Profile.create({ userId: user.id, bio }).
5.4. Updates and deletes
The toggle route flips published. Drizzle can do that in place with not(posts.published):
import { eq, not } from "drizzle-orm";
import { NextResponse } from "next/server";
import { db } from "@/db/drizzle";
import { posts } from "@/db/schema";
type Params = { params: Promise<{ id: string }> };
export async function PATCH(_request: Request, { params }: Params) {
const id = Number((await params).id);
const [post] = await db
.update(posts)
.set({ published: not(posts.published) })
.where(eq(posts.id, id))
.returning();
if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(post);
}
export async function DELETE(_request: Request, { params }: Params) {
const id = Number((await params).id);
const [post] = await db.delete(posts).where(eq(posts.id, id)).returning();
if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(post);
}Prisma 8 updates take a data object, so read the row first with .first({ id }) and write the negated value. .update() and .delete() act on one row selected by .where(...), return that row, and return null when nothing matched:
import { NextResponse } from "next/server";
import { db } from "@/prisma/db";
type Params = { params: Promise<{ id: string }> };
export async function PATCH(_request: Request, { params }: Params) {
const id = Number((await params).id);
const current = await db.orm.public.Post.first({ id });
if (!current) return NextResponse.json({ error: "Not found" }, { status: 404 });
const post = await db.orm.public.Post.where({ id }).update({ published: !current.published });
return NextResponse.json(post);
}
export async function DELETE(_request: Request, { params }: Params) {
const id = Number((await params).id);
const post = await db.orm.public.Post.where({ id }).delete();
if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(post);
}curl -X PATCH http://localhost:3000/api/posts/2
curl -X DELETE http://localhost:3000/api/posts/5
curl -X DELETE http://localhost:3000/api/posts/5{ "authorId": 2, "content": null, "createdAt": "2026-09-10 21:32:51.929589+06", "id": 2, "published": true, "title": "Typed queries" }
{ "authorId": 1, "content": "Migration notes", "createdAt": "2026-09-10 21:58:28.179323+06", "id": 5, "published": false, "title": "Drizzle to Prisma" }
{ "error": "Not found" }The onDelete: Cascade that came through inference still applies: deleting post 5 removed its two posts_to_categories rows. To update or delete several rows at once, use .updateAll(...) and .deleteAll() instead.
5.5. Remove Drizzle
When no file imports drizzle-orm any more, delete src/db/ and drizzle.config.ts and uninstall the packages:
bun remove drizzle-orm drizzle-kit pgKeep pg if other code uses it directly; Prisma 8 brings its own copy. Run next build to confirm the app compiles and type-checks without Drizzle.
Many-to-many relations in Prisma 8
Drizzle has no implicit many-to-many relations: you always declare the join table, as postsToCategories shows. Prisma 8 works the same way. contract infer gives you the join table as an explicit model with a composite primary key, and a list field on both sides that points at the join model:
model Post {
postsToCategories PostToCategory[]
}
model Category {
postsToCategories PostToCategory[]
}
model PostToCategory {
postId Int @map("post_id")
categoryId Int @map("category_id")
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@id([postId, categoryId])
@@map("posts_to_categories")
}The implicit form from Prisma 7, list fields on both sides with no join model, is not supported: the contract compiler rejects it and asks for an explicit join model. If you are coming from Prisma 7 and expected a hidden _CategoryToPost table, there is none; the join table you already have is the model.
What you can do is add direct list fields on both sides next to the join model, as step 3.3 did. The contract records the relation as many-to-many through the join table's columns, the storage hash does not change, and the direct fields unlock three query shapes:
// Categories nested directly under each post, without the join records
const posts = await db.orm.public.Post
.select("id", "title")
.include("categories")
.all();
// Posts that carry a given category
const tagged = await db.orm.public.Post
.where((p) => p.categories.some((c) => c.name.eq("databases")))
.select("id", "title")
.all();
// Link and unlink without touching the join model by hand
await db.orm.public.Post.where({ id: postId }).update({
categories: (c) => c.disconnect([{ id: 2 }]),
});
await db.orm.public.Post.where({ id: postId }).update({
categories: (c) => c.connect([{ id: 2 }]),
});[
{ id: 1, title: 'Hello Drizzle', categories: [ { id: 1, name: 'databases' }, { id: 2, name: 'typescript' } ] },
{ id: 2, title: 'Typed queries', categories: [ { id: 2, name: 'typescript' } ] }
]
[ { id: 1, title: 'Hello Drizzle' } ]disconnect removes only the join row; the Category record stays. The join model remains available for anything that needs the pair itself, for example db.orm.public.PostToCategory.create({ postId, categoryId }), and for a flat one-row-per-pair result you can join through it with the SQL builder. See Relational data modeling for the modeling rules and Relations and joins for the query side.
Common gotchas
If you edit the contract after signing, db verify fails with CONTRACT.MARKER_MISMATCH (Contract storageHash does not match database marker). When the database still matches the new contract, as with a type change from Timestamptz to TimestamptzString, run contract emit and then db sign again; the signature moves from the old hash to the new one. When the change needs new columns or tables, use db update or migration plan instead of re-signing.
- Inferred
Timestamptzfields fail at query time withRUNTIME.TEMPORAL_UNAVAILABLEon Node.js. Change them toTimestamptzStringbefore you emit, as in step 3.2. .count()is only valid inside an.include(...)callback. To count rows, use.aggregate((a) => ({ total: a.count() })).- In a long-running server, do not call
db.runtime().close()in route handlers; the connection pool is shared across requests. Close it only on process shutdown, and never in a Next.js route. - Bare
.update()and.delete()need a.where(...)and touch one row. Use.updateAll(...)and.deleteAll()for many rows.
Prompt your coding agent
Run npx prisma@latest init once to install the Prisma 8 skills for your coding agent and keep them matching your installed packages. Prompts that map to this guide:
- "Using the prisma-8 skill, migrate
src/app/api/categories/route.tsfrom Drizzle todb.orm.public.Categoryand run it with curl." - "Rename the inferred models in
src/prisma/contract.prismato singular names and keep every@@map, then emit and verify." - "Add a
GET /api/categories/[id]/postsroute that uses thepostslist field onCategory."
Next steps
- Learn the fundamentals: filtering, sorting, pagination, and writes.
- Evolve the schema with migrations now that Prisma 8 owns it.
- Read the Prisma 8 overview for the concepts behind contracts and typed queries.
