"I want type-safe data access"—as a requirement it's one line. But the moment you try to put it into production, the things to decide multiply at once. Where do you declare the schema? Where do the types come from? How do you avoid N+1 on relations? Where do you draw transaction boundaries? How do you generate, review, and apply migrations? How do you structurally prevent SQL injection? How do you avoid exhausting connections in serverless?
This article is an implementation guide to operating Prisma ORM at production quality in the v7 generation. Prisma is an ORM with the philosophy of "making the schema the single source of truth and deriving types, the client, and migrations all from it," widely used for its declarative data models and good developer experience. And in v7 (November 2025), the internal architecture changed significantly—the long-bundled Rust query engine was retired, driver adapters became mandatory, and the client generation method (generator) is new. This article, assuming v7, builds on the official documentation's facts as a foundation and layers on the design philosophy I always practice: "push runtime correctness into types as much as possible."
The rules of this article: API specs, commands, and code are based on the Prisma official documentation (as of June 2026, the v7 line). Prisma evolves actively, and generators, adapters, and CLI flags change by version (this article's
prisma-clientgenerator, mandatory driver adapters, andprisma.config.tsare all behaviors that became the default in v7). Always confirm the latest specs in the official docs before going to production. DB connection info (connection strings, credentials) is treated as assumed to be in environment variables (never hardcode). Places that include "Preview" features are noted.
0. Mental model: lock the ORM boundary with "types" too
Before the main topic, let me share the single axis running through this article.
In developing a type-safe subscription billing foundation, I fully banned as / any / enum / non-null assertions. Instead, I combined Union types + satisfies + NeverError(value: never) to create a state where "if a branch is missed, compilation fails," and validated every external-input boundary with Zod.
This philosophy—"knock out runtime invalidity at compile time as much as possible"—applies directly to the ORM boundary (DB access). If anything, the database is the boundary where types are most prone to collapse in an app. If a SELECT * result flows through as any, no matter how carefully you write the downstream logic, it's a castle on sand.
Prisma's mental model is this.
Declare the data model in
schema.prisma→prisma generateproduces a typed client corresponding to the schema → both the input and the result of a query are typed = the schema is the single source of truth.
The schema becomes the single source of truth, and types, the client, and migrations are all derived from it. This is the core of Prisma and the reason it lines up straight with my design principles. The biggest difference between Prisma and Drizzle boils down to "write the schema in a custom DSL (.prisma) or in TypeScript" (I organize when to use which in §13 below), but the discipline of "machine-generate types from the schema" is common to both.
1. What changed in v7 (grasp this first)
The longer you've used Prisma, the more you should read here first. v7 changed the setup, so code in old articles won't run as-is.
| Area | v6 (old) | v7 (current) |
|---|---|---|
| generator | prisma-client-js (generated into node_modules) | prisma-client (make the output target explicit with output) |
| Query engine | Bundled a Rust binary | Rust-free (TypeScript/WASM query compiler) |
| driver adapter | Optional (Preview) | Mandatory for all DBs (@prisma/adapter-pg for PostgreSQL) |
Middleware $use | Present | Removed → Client Extensions ($extends) |
Prisma.validator | Present | Treated as legacy → TypeScript's satisfies |
| Where config lives | package.json's "prisma" key | prisma.config.ts (defineConfig) |
| import source | @prisma/client | The output path (e.g. ../generated/prisma/client) |
Going Rust-free isn't merely an internal refresh; it has practical benefits for operations. According to the official blog, the new architecture makes queries up to 3.4× faster and shrinks the bundle size from about 14MB → 1.6MB (about 90% smaller). With the native-binary dependency gone, deploying to serverless/edge is dramatically simplified—the bigger the gain, the more cold start and artifact size matter in your environment.
Design decision: new projects should start on v7 (
prisma-clientgenerator) without hesitation. Existing v6 projects should migrate the generator, adapter, and import paths following the official v7 upgrade guide (I summarize the key points in §14).
2. Setup: generator, driver adapter, prisma.config.ts
2.1 Install
Let's take PostgreSQL as the subject. In place of the now-gone query engine, you explicitly add the driver adapter that handles connections.
npm install @prisma/client @prisma/adapter-pg
npm install -D prisma tsx
2.2 Schema and generator
At the top of prisma/schema.prisma, declare the datasource and the new generator. In v7, provider = "prisma-client", and output (the generation target) is mandatory.
// prisma/schema.prisma
datasource db {
provider = "postgresql"
}
generator client {
provider = "prisma-client" // v7の新generator(旧: prisma-client-js)
output = "../src/generated/prisma" // 生成先を明示(node_modulesには出ない)
runtime = "nodejs" // deno / bun / workerd / vercel-edge なども選べる
moduleFormat = "esm" // esm | cjs
}
This is v7's biggest change. The generated client is output not into node_modules but inside your repository (the output path). That is, the generated artifact becomes visible "as part of your own code," and the import source becomes the output path, not @prisma/client. Being able to generate clients for Bun / Deno / Cloudflare Workers / Vercel Edge by just switching runtime is also a fruit of going Rust-free (= no native-binary dependency).
An operational tip: the directory you specify in
output(e.g.src/generated/) is a generated artifact, so put it in.gitignore, and always runprisma generatebefore the CI and deploy builds (inpostinstallor an explicit build step). Whether to commit the artifact is a team policy, but at minimum it's important to plug "shipping to production with generation forgotten" via your build procedure.
2.3 prisma.config.ts (v7's config starting point)
In v7, you declare how the CLI handles schema, migrations, and seeds type-safely in prisma.config.ts (at the project root). Environment variables are not loaded automatically, so load them explicitly with dotenv or similar.
// prisma.config.ts(プロジェクト直下)
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts", // シードは migrations.seed 配下
},
datasource: {
url: env("DATABASE_URL"),
},
});
By making config into code, reproducibility is guaranteed in monorepos and deploys, and the ambiguity from when it depended on a string key in package.json disappears.
2.4 Creating PrismaClient (adapter mandatory)
In v7, passing a driver adapter when creating the client is mandatory. This becomes the "DB handle" you reuse across the whole app.
// src/db.ts
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "./generated/prisma/client"; // ← 生成先からimport
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
export const prisma = new PrismaClient({ adapter });
Note that PrismaClient is imported from the generated ./generated/prisma/client, not from @prisma/client. Articles describing v7 tend to trip up here. The direction has been reorganized so that connection-pool tuning is done with the adapter's options rather than the connection string's query parameters (the pool defaults were also revisited in v7).
3. Schema-as-code: declare with model
Prisma's schema declares "the shape of data" with model blocks. This is the source of types, the client, and all migrations.
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
role Role @default(USER)
posts Post[]
profile Profile?
}
model Profile {
id Int @id @default(autoincrement())
bio String
user User @relation(fields: [userId], references: [id])
userId Int @unique
}
model Post {
id Int @id @default(autoincrement())
title String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
comments Comment[]
createdAt DateTime @default(now())
@@index([authorId, title])
}
model Comment {
id Int @id @default(autoincrement())
content String
post Post @relation(fields: [postId], references: [id])
postId Int
@@map("comments") // 物理テーブル名を comments に
}
enum Role {
USER
ADMIN
}
Let me pin the reading points from a design perspective.
@id @default(autoincrement())is the primary key and auto-numbering.@uniqueis a uniqueness constraint. The?ofString?means NULL-allowed (=name: string | null).- Relations come in pairs of two. On the
Postside,author User @relation(fields: [authorId], references: [id])is the foreign-key side, and on theUserside,posts Post[]is the back-reference. Adding@uniquetoProfile.userIdexpresses one-to-one ("one profile per user") at the type level. @@index([authorId, title])is a composite index, and@@map("comments")separates the model name (in code) from the physical table name (in the DB). Code isComment, DB iscomments—this separation helps when retrofitting into an existing DB.enumcorresponds to a DB enum type and is handled type-safely likeRole.USER(I avoid hand-written string-literal enums and make it schema-driven this way).
A design tip (SRP): the schema sticks to the single responsibility of "the shape of data." Don't mix in aggregation views or app-specific derived values; keep business logic in the app layer. If the schema bloats, Prisma also supports splitting it into multiple files.
After rewriting the schema, regenerate the typed client with npx prisma generate (the migration commands below also handle generation).
4. Type safety: "let it be inferred," don't name the result type
Here's Prisma's true value. From the schema, both the input type and the result type are machine-generated. Crossing this boundary with a hand-written DTO type or an as cast is an act of throwing away type safety yourself.
import { Prisma } from "./generated/prisma/client";
// 1) 入力型:スキーマの制約(idは自動採番なので不要、nameは任意)が型に反映される
const data: Prisma.UserCreateInput = {
email: "hinata@example.com",
posts: { create: { title: "Hello Prisma v7" } },
};
// 2) 結果型:include/select の「形」から正確な戻り型を導出する
const userWithPosts = { include: { posts: true } } satisfies Prisma.UserDefaultArgs;
type UserWithPosts = Prisma.UserGetPayload<typeof userWithPosts>;
// UserWithPosts は { id; email; name: string | null; ...; posts: Post[] } に正確に推論される
Two points.
- Using a generated type like
Prisma.UserCreateInputmakes "what you can and can't pass on INSERT" match the schema exactly.idcan't be passed because it'sautoincrement(), andnamecan be omitted because it's?—you don't need a human to keep this diff in sync forever (structurally eliminating a textbook DRY violation). Prisma.UserGetPayload<typeof args>derives the exact result type from the "shape to fetch" specified byinclude/select. If you includedposts,postsappears in the result type; if not, it doesn't. Combining this withsatisfies(a native TypeScript operator) is the v7-recommended practice.
A v7 caveat: the old
Prisma.validator<...>()is legacy (prisma-client-js-only) and can't be used with the newprisma-clientgenerator. Use thesatisfies+GetPayloadabove instead. "Let the boundary type be inferred; don't name it yourself"—this is the first discipline of using Prisma.
5. CRUD: a panorama of basic operations
It's a unified API of prisma.<model>.<operation>. That where / select / orderBy / take / skip and the rest have consistent names and meanings across models is Prisma's high learning efficiency.
// create
await prisma.user.create({
data: { email: "elsa@prisma.io", name: "Elsa" },
});
// createMany(重複は skipDuplicates で握りつぶせる)
await prisma.user.createMany({
data: [
{ email: "bob@prisma.io", name: "Bob" },
{ email: "yewande@prisma.io", name: "Yewande" },
],
skipDuplicates: true,
});
// findUnique(一意キーでの単一取得)
const user = await prisma.user.findUnique({ where: { email: "elsa@prisma.io" } });
// findMany(条件・並び・ページング・必要な列だけ)
const users = await prisma.user.findMany({
where: { email: { endsWith: "@prisma.io" } },
select: { id: true, email: true }, // 取りすぎ防止(コスト&機密の最小化)
orderBy: { id: "asc" },
take: 10,
skip: 20,
});
// update / upsert / delete
await prisma.user.update({
where: { email: "elsa@prisma.io" },
data: { name: "Elsa the Great" },
});
await prisma.user.upsert({
where: { email: "viola@prisma.io" },
update: { name: "Viola v2" },
create: { email: "viola@prisma.io", name: "Viola" },
});
await prisma.user.delete({ where: { email: "bob@prisma.io" } });
Three practical tips.
- Narrowing the columns you fetch with
selectpays off both for performance and minimizing sensitive data. Keeping a column likepassword_hashin a state where "it won't leak if you don'tselectit" is a good default that errs on the safe side. upsertis the three-piece set ofwhere(find),update(if present),create(if absent). This becomes the protagonist of the idempotency design below.- Paging comes in two methods:
take/skip(offset) andcursor. On large tables, offset degrades on deep pages, so choose thecursor: { id: lastId }+orderBycursor method.
6. Relations and N+1: fetch "in one go" with include / select
The most accident-prone thing in ORMs is the N+1 problem (fetch a list, then pull relations one by one, and the query count balloons to the number of rows). In Prisma you fetch relations declaratively with include (whole) / select (chosen), and no extra query is issued per row, so written plainly, no N+1 appears.
// include:関連レコードを丸ごと
const usersWithPosts = await prisma.user.findMany({
include: { posts: true },
});
// select:ネストして、必要な列だけ
const slim = await prisma.user.findMany({
select: {
name: true,
posts: { select: { title: true } },
},
});
Nested writes are also powerful. You can create parent and child in one transaction (no explicit $transaction needed—the official docs define "a nested write performs multiple operations in a single transaction").
// 親ユーザーと記事を同時に作成
await prisma.user.create({
data: {
email: "vlad@prisma.io",
posts: { create: [{ title: "First" }, { title: "Second" }] },
},
});
// 既存を connect、無ければ作る connectOrCreate
await prisma.post.create({
data: {
title: "How to make croissants",
author: {
connectOrCreate: {
where: { email: "viola@prisma.io" },
create: { email: "viola@prisma.io", name: "Viola" },
},
},
},
});
Advanced (Preview feature): there's
relationLoadStrategy: "join" | "query"that chooses the relation-loading strategy at fetch time."join"makes it a single query with aLATERAL JOIN(on PostgreSQL) etc., and"query"joins multiple queries on the app side. This feature is Preview at the time of writing, so make production adoption contingent on a feature flag and verification. Either way, sinceinclude/selectdon't increase queries in proportion to row count, written plainly, N+1 is avoided.
7. Transactions and idempotency: lock the boundary with code
Processing that needs "correctness," like payments or inventory, is guarded not by operational carefulness but by the structure of code. Prisma's transactions come in two lines.
7.1 Array form (bundle independent multiple queries atomically)
const [posts, total] = await prisma.$transaction([
prisma.post.findMany({ where: { title: { contains: "prisma" } } }),
prisma.post.count(),
]);
7.2 Interactive form (a sequence that branches on intermediate values)
import { Prisma } from "./generated/prisma/client";
await prisma.$transaction(
async (tx) => {
const sender = await tx.account.update({
where: { email: "alice@prisma.io" },
data: { balance: { decrement: 100 } },
});
if (sender.balance < 0) throw new Error("Insufficient funds"); // throwで全ロールバック
await tx.account.update({
where: { email: "bob@prisma.io" },
data: { balance: { increment: 100 } },
});
},
{
maxWait: 5_000, // トランザクション開始を待つ上限
timeout: 10_000, // トランザクション全体の上限
isolationLevel: Prisma.TransactionIsolationLevel.Serializable, // 競合に強い分離レベル
},
);
Three disciplines you can't skip in practice.
- Inside a transaction, always use
tx. If you call the outerprismainstead oftx, that operation runs outside the transaction and atomicity breaks. - Keep external API calls (payment providers, etc.) outside the transaction boundary. Holding network I/O inside a transaction occupies the DB connection and locks for a long time, becoming a breeding ground for timeouts and deadlocks.
- Build idempotency with
upsertand uniqueness constraints. Assuming retries and duplicate delivery,upserton a@uniqueidempotency key (idempotencyKey, etc.) makes "the same operation arriving twice yield one operation's result." Catching and swallowingP2002(uniqueness violation) on a duplicate is also a standard technique (§9).
Related: the design philosophy of idempotency and double-charge prevention is dug into in a separate article on payments. The thinking that supported zero double-charges in production is universal whether the ORM is Prisma, Drizzle, or even DynamoDB.
8. Raw SQL and security: always parameterize
For aggregations or DB-specific features hard to express in the ORM, you use raw SQL, but this is the biggest entrance for SQL injection. Prisma's safe way is tagged templates ($queryRaw / $executeRaw). ${} becomes a bound parameter (a prepared statement), not string concatenation, so injection structurally cannot occur.
// ✅ 安全:${email} はパラメータとして束縛される
const email = "emelie@prisma.io";
const rows = await prisma.$queryRaw`SELECT id, name FROM "User" WHERE email = ${email}`;
// ✅ IN 句は Prisma.join で安全に展開
import { Prisma } from "./generated/prisma/client";
const ids = [1, 3, 5, 10];
await prisma.$queryRaw`SELECT * FROM "User" WHERE id IN (${Prisma.join(ids)})`;
// ✅ 条件付き断片は Prisma.sql / Prisma.empty で組み立てる
const name: string | null = null;
await prisma.$queryRaw`
SELECT * FROM "User"
${name ? Prisma.sql`WHERE name = ${name}` : Prisma.empty}
`;
// ✅ 件数を返す書き込み系は $executeRaw(影響行数を number で返す)
const affected: number = await prisma.$executeRaw`
UPDATE "User" SET active = ${true} WHERE "emailValidated" = ${true}
`;
Conversely, here's the form you must not use.
// ❌ 厳禁:文字列連結はインジェクションの穴。ユーザー入力を絶対に渡さない
const input = '"x" UNION SELECT id, title FROM "Post"';
await prisma.$queryRawUnsafe("SELECT id, name FROM \"User\" WHERE name = " + input);
$queryRawUnsafe and Prisma.raw(...) are APIs that splice in "an unescaped raw fragment," and passing user input becomes a vulnerability immediately. Even when you must use them, guarantee the input is a fixed value under your control, and always put external input on the parameter side (${} / Prisma.join). Note that Prisma also offers TypedSQL for writing raw SQL type-safely, and when raw SQL is needed it's worth considering this first.
9. Error handling: branch on codes
Prisma's known errors are thrown as Prisma.PrismaClientKnownRequestError, and you can distinguish the cause by code. In v7, you also import the Prisma namespace from the output path.
import { Prisma } from "./generated/prisma/client";
try {
await prisma.user.create({ data: { email: "dup@example.com" } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === "P2002") {
// 一意制約違反:冪等な再試行なら 200/既存を返す、UI なら「既に登録済み」
return { ok: false, reason: "duplicate" as const };
}
if (e.code === "P2025") {
// 対象レコードが無い(update/delete の前提が崩れた)
return { ok: false, reason: "not_found" as const };
}
}
throw e; // 想定外は握りつぶさず再送出(可観測性のため)
}
The representative codes are P2002 (uniqueness violation) and P2025 (target record not found). A design that translates these into a typed result (a Union) so that compilation fails if there's a missing switch case on the caller side turns error handling from "prayer" into "guarantee." Not swallowing unexpected errors either (rethrow and leave them in logs/traces) is also important for observability.
10. Client Extensions: add cross-cutting concerns type-safely (middleware removed in v7)
In v7, middleware ($use) was removed, and Client Extensions ($extends) became the official extension point. With the four kinds model / client / query / result, you can splice in adding computed columns, query logging, and the like type-safely.
// query 拡張:全モデル・全操作の所要時間をログ(可観測性)
const prisma = basePrisma.$extends({
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
const start = performance.now();
const result = await query(args);
const ms = Math.round(performance.now() - start);
console.log(JSON.stringify({ event: "db_query", model, operation, ms }));
return result;
},
},
},
});
Unlike middleware, Extensions return a new client per extension, so you can limit the scope of application and types are preserved. It's the standard practice in production operations to make logs structured logs with a correlation ID, in a form you can cross-reference with traces.
11. Migrations: separate dev and deploy correctly
What version-controls schema changes and applies them safely to production is Prisma Migrate. Getting the division of command roles wrong loses data in production—I'll emphasize this.
# 開発:差分から新しいマイグレーションを生成して即適用(+クライアント再生成)
npx prisma migrate dev --name add_user_role
# 本番/CI:保留中のマイグレーションだけを順に適用(生成もリセットもしない)
npx prisma migrate deploy
# 試作のみ:マイグレーション履歴を残さずスキーマをDBへ同期(使い捨て)
npx prisma db push
| Command | Purpose | Important property |
|---|---|---|
migrate dev | Dev only | Needs a shadow DB to detect diffs. On drift detection it can reset the DB (= discard data). Must never be run in production |
migrate deploy | Production/CI | Applies only pending migrations. Safe because it uses no drift check, reset, or shadow DB |
db push | Prototyping, schema experiments | Doesn't create _prisma_migrations and leaves no history. For disposable verification where version control isn't needed |
The shadow DB is a temporary DB that migrate dev automatically creates and deletes to detect schema drift and data loss. It's unnecessary in production (migrate deploy doesn't use it), but note that you need connection privileges to create one in the dev environment / CI when generating migrations.
Production flow: in dev,
migrate devto generate the migration file → a human must review the generated SQL (for destructive changes) → commit → CI applies to production withmigrate deploy. Zero-downtime schema changes (lock-avoiding DDL, staged application of add column → backfill → add constraint) are guaranteed by inspecting the generated SQL at this review stage.
12. Serverless / Next.js: don't exhaust connections
The most common Prisma production accident is connection exhaustion. In serverless, function instances spin up en masse, and if each holds a DB connection, it eats up PostgreSQL's max_connections in no time. Going Rust-free made artifacts lighter, but the principles of connection management are unchanged.
12.1 Hot-reload countermeasure in dev (singleton)
Next.js's next dev tends to recreate PrismaClient on each reload, multiplying connections. Hold it on globalThis and reuse it.
// src/db.ts
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "./generated/prisma/client";
const createClient = () =>
new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }) });
const globalForPrisma = globalThis as unknown as { prisma?: ReturnType<typeof createClient> };
export const prisma = globalForPrisma.prisma ?? createClient();
// 本番ではグローバルに保持しない(コールドスタートごとに1個で十分)
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
12.2 Principles for serverless production
- Create the client outside the handler and reuse it across warm instances.
- Don't call
$disconnect()on every request. Repeatedly establishing and tearing down connections becomes a cost instead. - Insert an external pooler. In a configuration where many functions hit the DB directly, put a pooler like PgBouncer—or the Prisma Accelerate / Prisma Postgres below—in front.
12.3 Prisma Postgres / Accelerate (managed connection pool and cache)
Prisma Postgres is a managed PostgreSQL with a built-in PgBouncer connection pool that resolves serverless/edge connection exhaustion on the configuration side (pooling and edge support take effect automatically with a normal connection string). Prisma Accelerate is a global connection-pool + query-cache layer usable even with external DBs; you $extends @prisma/extension-accelerate's withAccelerate() and can specify cacheStrategy per query.
import { withAccelerate } from "@prisma/extension-accelerate";
const prisma = basePrisma.$extends(withAccelerate());
// クエリ単位でキャッシュ(ttl=鮮度、swr=stale許容秒)
const posts = await prisma.post.findMany({
where: { published: true },
cacheStrategy: { ttl: 60, swr: 10 },
});
Caveat: Accelerate's exact connection string and initialization (especially combined with v7's driver adapter) move in detail by version, so always confirm the latest procedure in the official docs before going to production. The line "use
cacheStrategyonly for cacheable reads, and don't attach it to reads that need consistency" is important.
13. Prisma or Drizzle: the axes for choosing
In the arena of "type-safe TypeScript ORMs," the one always compared is Drizzle. I use both in production, but my selection axes are simple.
| Aspect | Prisma | Drizzle |
|---|---|---|
| How to write the schema | Custom DSL (schema.prisma). Declarative and readable | TypeScript (pgTable). No extra thing to learn |
| Abstraction level | Higher. Relation fetching is declarative and easy | Lower. Close to SQL, high transparency |
| Source of types | Schema → generated by prisma generate | Schema (TS) → inferred with $inferSelect |
| Migrations | Prisma Migrate (mature, thick operational features) | drizzle-kit (generate→apply is simple) |
| Edge / lightness | Greatly improved by going Rust-free in v7 | Lightweight from the start, thin dependencies |
| Managed integration | Prisma Postgres / Accelerate are strong | Adapters support each vendor's serverless DB |
A rough guideline:
- You want to go to production fast with declarative data models, thick migration operations, and a managed connection pool/cache included → Prisma.
- You want to keep SQL transparency, minimize dependencies, and finish with inference rather than generated artifacts → Drizzle.
Both "make the schema the single source of truth and machine-generate types," and as long as you keep the discipline of not crossing the boundary with as/any, you can reach production quality. Choose by your project's constraints (existing DB, the team's SQL fluency, deploy target); there's no need to make it a holy war.
14. Key points of v6 → v7 migration
The minimal checklist for upgrading an existing project to v7 (details in the official v7 upgrade guide).
- Change the generator to
prisma-clientand makeoutputmandatory (break away fromnode_modulesgeneration). - Introduce a driver adapter (
@prisma/adapter-pgfor PostgreSQL) and rewrite tonew PrismaClient({ adapter }). - Change the import source to the output path (
@prisma/client→./generated/prisma/client). Same for thePrismanamespace and error types. - Port
$usemiddleware to Client Extensions ($extends). - Replace
Prisma.validatorwithsatisfies+GetPayload. - Move config to
prisma.config.ts(defineConfig). Also confirm that some CLI flags like--skip-generate/--skip-seedwere removed. - Guarantee that
prisma generateruns before the CI/deploy build, and apply to production withmigrate deploy.
The iron rule of staged migration is "first get it into a working form, then optimize." Fix the three points—output, adapter, import—and it works first; making it Extensions and introducing Accelerate can follow.
15. Production-launch checklist
Finally, the items I always confirm before putting Prisma into production.
- The generator is
prisma-client,outputis explicit, the artifact is in.gitignore+prisma generateis enforced in the build -
PrismaClientis created with a driver adapter, and is one instance across the whole app (outside the handler for serverless) - Connection strings and credentials are environment variables only (no hardcoding, no log output)
- Result types are inferred with
GetPayload/satisfies. Don't cross the DB boundary withas/any - List × relation is fetched with
include/select, and whether N+1 appears is checked in the query log - Processing that needs "correctness" uses
$transaction(the interactive form usestxonly, external I/O is outside the boundary,isolationLevelif needed) - Idempotency is a
@uniquekey +upsert, andP2002/P2025are translated into typed results - Raw SQL is always parameterized with
$queryRawtagged templates. Don't pass user input to$queryRawUnsafe/Prisma.raw - Migrations are dev
migrate dev→ SQL review → CImigrate deploy. Don't usedb pushin production - Serverless has connection-exhaustion countermeasures (pooler / Prisma Postgres / Accelerate) and a clear line on
cacheStrategyconsistency - Query durations are made structured logs with Client Extensions and correlated with traces
Summary
Prisma v7, by retiring the Rust engine and making driver adapters mandatory, advanced one step into an ORM that is "light and fast, riding serverless/edge naturally." The setup changed, but the essence is consistent—make the schema the single source of truth, derive types, the client, and migrations from it, don't cross the boundary with as/any, and guarantee correctness with the structure of code (transactions, idempotency, parameterization). Keep this discipline and Prisma becomes a data-access layer that is "fast to write and doesn't break in production."
"I want to stand up a type-safe data layer at production quality, fast"—if you have such a requirement, I can help turn it into implementation, from choosing Prisma/Drizzle to migration operations and serverless connection design.