An ORM (object-relational mapper) turns work with objects in code into SQL queries against a relational database: in Prisma you describe models in schema.prisma, get a generated typed client, and prisma migrate dev turns each schema change into a SQL migration file that lives in Git. An ORM speeds up routine CRUD and catches typos, but it doesn't replace SQL knowledge: generated migrations need reading, N+1 needs watching, and complex reports are easier to write in raw SQL.
After this you will be able to
- describe models and relations in
schema.prismaand read the SQL thatprisma migrate devgenerates from them - create, edit and apply a migration, including renaming a column without losing data
- run typed queries with relations and a transaction, and spot N+1 by counting SQL queries
- explain when an ORM gets in the way and a query is better written in SQL with
$queryRaw
Know beforehand
Verified with
Prisma ORM 7.10.0PostgreSQL 17.11 (postgres:17-alpine)Node.js 24 LTS (24.18)TypeScript 5.9.3pg 8.23.1tsx 4.23.15
Checked against docs: October 11, 2026
Contents
- ORM: what it gives you and what it costs
- Project: Prisma 7.10 and PostgreSQL 17 in Docker
- Schema: models, relations and database names
- Migrations: migrate dev, SQL and history
- Queries: CRUD, relations and a transaction
- N+1: how to see it and fix it
- When to write raw SQL, and what exists besides Prisma
- Migrations in a team and in production, errors and exercises
ORM: what it gives you and what it costs
An ORM is a layer between your code and the database. Instead of writing SELECT … FROM appointments JOIN clients … you call prisma.appointment.findMany({ include: { client: true } }) and get back an array of typed objects. Prisma is an ORM for Node.js and TypeScript with three parts: the schema.prisma file (models and relations), Prisma Client (generated from the schema) and Prisma Migrate (migrations — SQL files that bring the database to the schema step by step).
What an ORM gives you
- Types from the schema: the compiler catches a misspelled field, not your users
- Relations and nested writes without hand-written JOINs or manual object assembly
- Migrations: schema history in Git, identical for every environment
- Parameterized queries by default — values are never glued into SQL
What it costs
- One more layer: you need to know what SQL it sends
- It's easy to get N+1 without noticing — dozens of queries instead of two
- Reports, window functions and PostgreSQL-specific features are simpler in SQL
- Dependence on the tool and its major upgrades
This lesson continues the one on designing relations: the same tables, foreign keys and indexes, now described in a Prisma schema. The domain is a booking service: clients, services and time-slot appointments. By the end you'll have a schema, three migrations, a CRUD script, a transaction, an N+1 measurement and a SQL report.
Project: Prisma 7.10 and PostgreSQL 17 in Docker
The lesson environment: Node.js 24 LTS, PostgreSQL 17 in a container and TypeScript 5.9. According to the upgrade guide, Prisma 7 needs at least Node.js 20.19 and TypeScript 5.4, with TypeScript 5.9 recommended. Port 55817 avoids a clash with a local PostgreSQL on 5432; the test password is only acceptable for a throwaway container.
docker run -d --rm --name booking-pg -e POSTGRES_PASSWORD=test -e POSTGRES_DB=booking -p 127.0.0.1:55817:5432 postgres:17-alpine
mkdir booking && cd booking
npm init -y
npm pkg set type=module
npm install @prisma/client@7.10.0 @prisma/adapter-pg@7.10.0 pg@8.23.1 dotenv@18.0.7
npm install -D prisma@7.10.0 typescript@5.9.3 tsx@4.23.15 @types/node@24 @types/pg@8.23.1
npx prisma init --datasource-provider postgresql --output ../src/generated/prisma --no-skillsPrisma 7 differs a lot from version 6, and many articles and forum answers still describe the old behavior. The main changes from the official upgrade guide:
Generator
- Prisma 6
prisma-client-js, client innode_modules- Prisma 7
prisma-clientwith a requiredoutput; import from./generated/prisma/client
Connection string
- Prisma 6
urlin the schema'sdatasourceblock- Prisma 7
- in the Prisma config file (
prisma.config.tsorprisma7.config.ts)
Database connection
- Prisma 6
- built-in query engine
- Prisma 7
- a driver adapter is required; for PostgreSQL —
@prisma/adapter-pgon top ofpg
.env file
- Prisma 6
- loaded automatically
- Prisma 7
- not loaded: add
import "dotenv/config"
migrate dev
- Prisma 6
- ran
generateand seeding itself - Prisma 7
- doesn't: run
prisma generateandprisma db seedseparately
Modules
- Prisma 6
- CommonJS was fine
- Prisma 7
- the project is ESM,
"type": "module"
One more 7.10 detail: prisma init creates prisma7.config.ts, not the prisma.config.ts the docs talk about. The 7.10.0 release notes say the version 7 CLI looks for prisma7.config.* first, then .config/prisma7.*, and only then the existing prisma.config.* files — so Prisma 7 and 8 projects can live side by side without name clashes. Existing projects with prisma.config.ts keep working.
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});DATABASE_URL="postgresql://postgres:test@localhost:55817/booking"{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src", "prisma7.config.ts"]
}import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "./generated/prisma/client";
const connectionString = process.env.DATABASE_URL;
if (!connectionString) throw new Error("DATABASE_URL is not set");
// Prisma 7: the client talks to PostgreSQL through a driver adapter (node-postgres)
const adapter = new PrismaPg({ connectionString });
export const prisma = new PrismaClient({
adapter,
log: [{ emit: "event", level: "query" }],
});
// Count SQL queries so that N+1 is visible in numbers, not by feel
export const sql = { count: 0, print: false };
prisma.$on("query", (e) => {
sql.count++;
if (sql.print) console.log(` SQL: ${e.query}`);
});The PrismaPg adapter bridges the generated client and the pg driver. The upgrade guide warns that connection pool settings now come from the driver, and pg has no connection timeout by default (Prisma 6 used 5 seconds). The query event subscription is for the N+1 section: it lets you count queries instead of guessing.
Schema: models, relations and database names
The schema describes the client generator, the data source and the models. The booking service has three: Client, Service and Appointment. A client and a service can each have many appointments — two one-to-many relations. Here is the schema before the first migration:
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
model Client {
id Int @id @default(autoincrement())
name String
phone String @unique
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
appointments Appointment[]
@@map("clients")
}
model Service {
id Int @id @default(autoincrement())
title String @unique
durationMin Int @map("duration_min")
priceKzt Int @map("price_kzt")
appointments Appointment[]
@@map("services")
}
model Appointment {
id Int @id @default(autoincrement())
startsAt DateTime @map("starts_at") @db.Timestamptz(3)
clientId Int @map("client_id")
serviceId Int @map("service_id")
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
service Service @relation(fields: [serviceId], references: [id], onDelete: Restrict)
@@unique([serviceId, startsAt])
@@index([clientId])
@@map("appointments")
}@id @default(autoincrement())
- What you get in PostgreSQL
- a
SERIALcolumn and a primary key
@unique
- What you get in PostgreSQL
- a unique index such as
clients_phone_key
@relation(fields, references, onDelete)
- What you get in PostgreSQL
- a foreign key with
ON DELETE CASCADEorRESTRICT
@map / @@map
- What you get in PostgreSQL
- snake_case names in the database, camelCase in code
@db.Timestamptz(3)
- What you get in PostgreSQL
TIMESTAMPTZ(3)instead of the defaultTIMESTAMP(3)
@@unique([serviceId, startsAt])
- What you get in PostgreSQL
- one appointment per service at a given moment
@@index([clientId])
- What you get in PostgreSQL
- a regular index for looking up a client's appointments
The list field (appointments Appointment[]) doesn't exist in the database: it's the back side of the relation that Prisma Client uses. The real column is clientId on the appointment, and @relation declares it a foreign key. The delete policies are deliberate: deleting a client cascades to their appointments, while a service with appointments can't be deleted (Restrict) so history isn't lost.
About time: Prisma's DateTime maps to TIMESTAMP(3) without a time zone by default — you can see it in the first draft of the migration. For appointment times in Almaty or anywhere else, TIMESTAMPTZ is safer: PostgreSQL stores an unambiguous point in time, and the client gets it as a Date in UTC.
Migrations: migrate dev, SQL and history
prisma migrate dev compares the schema with the migration history, generates a new SQL file, applies it to the development database and records it in the _prisma_migrations table. To check the history it uses a shadow database: it replays every migration from scratch there and compares the result.
$ npx prisma migrate dev --name init
Loaded Prisma config from prisma7.config.ts.
Prisma schema loaded from prisma\schema.prisma.
Datasource "db": PostgreSQL database "booking", schema "public" at "localhost:55817"
Applying migration `20261012005404_init`
The following migration(s) have been created and applied from new schema changes:
prisma\migrations/
└─ 20261012005404_init/
└─ migration.sql
Your database is now in sync with your schema.
┌─────────────────────────────────────────────────────────┐
│ Update available 7.10.0 -> 8.0.0-rc.22 │
│ │
│ This is a major update - please follow the guide at │
│ https://pris.ly/d/major-version-upgrade │
│ │
│ Run the following to update │
│ npm i --save-dev prisma@latest │
│ npm i @prisma/client@latest │
└─────────────────────────────────────────────────────────┘-- CreateTable
CREATE TABLE "clients" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "clients_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "services" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"duration_min" INTEGER NOT NULL,
"price_kzt" INTEGER NOT NULL,
CONSTRAINT "services_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "appointments" (
"id" SERIAL NOT NULL,
"starts_at" TIMESTAMPTZ(3) NOT NULL,
"client_id" INTEGER NOT NULL,
"service_id" INTEGER NOT NULL,
CONSTRAINT "appointments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "clients_phone_key" ON "clients"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "services_title_key" ON "services"("title");
-- CreateIndex
CREATE INDEX "appointments_client_id_idx" ON "appointments"("client_id");
-- CreateIndex
CREATE UNIQUE INDEX "appointments_service_id_starts_at_key" ON "appointments"("service_id", "starts_at");
-- AddForeignKey
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_service_id_fkey" FOREIGN KEY ("service_id") REFERENCES "services"("id") ON DELETE RESTRICT ON UPDATE CASCADE;Read generated SQL as carefully as a colleague's code: it shows which constraints and indexes will actually appear in the database. Next to the migration folders sits migration_lock.toml with the database provider — the comment inside the file says to keep it in version control along with the migrations.
The second migration adds an appointment status and an optional note. Prisma turns the enum into a PostgreSQL type:
model Appointment {
id Int @id @default(autoincrement())
startsAt DateTime @map("starts_at") @db.Timestamptz(3)
clientId Int @map("client_id")
serviceId Int @map("service_id")
status AppointmentStatus @default(BOOKED)
note String?
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
service Service @relation(fields: [serviceId], references: [id], onDelete: Restrict)
@@unique([serviceId, startsAt])
@@index([clientId])
@@map("appointments")
}
enum AppointmentStatus {
BOOKED
CANCELLED
DONE
@@map("appointment_status")
}-- CreateEnum
CREATE TYPE "appointment_status" AS ENUM ('BOOKED', 'CANCELLED', 'DONE');
-- AlterTable
ALTER TABLE "appointments" ADD COLUMN "note" TEXT,
ADD COLUMN "status" "appointment_status" NOT NULL DEFAULT 'BOOKED';The third migration shows the main risk of generated migrations. Let's rename note to comment. Prisma doesn't know about renames: to it, one field disappeared and another appeared. So we create the migration with --create-only — the file is written but not applied:
$ npx prisma migrate dev --name rename_note_to_comment --create-only
Loaded Prisma config from prisma7.config.ts.
Prisma schema loaded from prisma\schema.prisma.
Datasource "db": PostgreSQL database "booking", schema "public" at "localhost:55817"
$ cat prisma/migrations/20261012005439_rename_note_to_comment/migration.sql
/*
Warnings:
- You are about to drop the column `note` on the `appointments` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "appointments" DROP COLUMN "note",
ADD COLUMN "comment" TEXT;-- Rename instead of drop + add: keeps existing data
ALTER TABLE "appointments" RENAME COLUMN "note" TO "comment";$ npx prisma migrate dev
...
migrations/
└─ 20261012005439_rename_note_to_comment/
└─ migration.sql
Your database is now in sync with your schema.
$ docker exec booking-pg psql -U postgres -d booking -c "SELECT id, status, comment FROM appointments"
id | status | comment
----+--------+---------
1 | BOOKED | keep me
(1 row)
$ npx prisma generate
✔ Generated Prisma Client (7.10.0) to .\src\generated\prisma in 46msServers run migrate deploy, not migrate dev: the docs call migrate dev and migrate reset development commands that must never run in production. migrate deploy only applies pending migrations — no shadow database, no reset, no client generation. A check against an empty database standing in for staging:
$ DATABASE_URL="postgresql://postgres:test@localhost:55817/booking_stage" npx prisma migrate deploy
Loaded Prisma config from prisma7.config.ts.
Prisma schema loaded from prisma\schema.prisma.
Datasource "db": PostgreSQL database "booking_stage", schema "public" at "localhost:55817"
3 migrations found in prisma/migrations
Applying migration `20261012005404_init`
Applying migration `20261012005422_add_status_and_note`
Applying migration `20261012005439_rename_note_to_comment`
The following migration(s) have been applied:
migrations/
└─ 20261012005404_init/
└─ migration.sql
└─ 20261012005422_add_status_and_note/
└─ migration.sql
└─ 20261012005439_rename_note_to_comment/
└─ migration.sql
All migrations have been successfully applied.If someone changes the development database by hand, migrate dev detects that it has drifted from the history and offers to reset the schema. In development that means losing test data, so every structural change goes through migrations:
$ docker exec booking-pg psql -U postgres -d booking -c "ALTER TABLE clients ADD COLUMN email text"
$ npx prisma migrate dev --name noop
...
Drift detected: Your database schema is not in sync with your migration history.
The following is a summary of the differences between the expected database schema given your migrations files, and the actual schema of the database.
It should be understood as the set of changes to get from the expected schema to the actual schema.
[*] Changed the `clients` table
[+] Added column `email`
We need to reset the "public" schema at "localhost:55817"
You may use prisma migrate reset to drop the development database.
All data will be lost.Queries: CRUD, relations and a transaction
The script below runs a full cycle on an empty database: it creates services, creates a client together with their first appointment (a nested write), reads the schedule with relations, updates the appointment and checks both delete policies.
import { Prisma } from "./generated/prisma/client";
import { prisma } from "./db";
// Create: plain records
const haircut = await prisma.service.create({
data: { title: "Haircut", durationMin: 60, priceKzt: 8000 },
});
const manicure = await prisma.service.create({
data: { title: "Manicure", durationMin: 90, priceKzt: 10000 },
});
// Nested write: a client and their first booking in one transaction
const client = await prisma.client.create({
data: {
name: "Aigerim",
phone: "+77010000001",
appointments: {
create: { serviceId: haircut.id, startsAt: new Date("2026-10-20T10:00:00+05:00") },
},
},
include: { appointments: true },
});
console.log("created:", client.name, "bookings:", client.appointments.length);
// Read: relation via include, only needed fields via select
const schedule = await prisma.appointment.findMany({
where: { startsAt: { gte: new Date("2026-10-20T00:00:00+05:00") } },
orderBy: { startsAt: "asc" },
select: {
id: true,
startsAt: true,
status: true,
client: { select: { name: true } },
service: { select: { title: true, priceKzt: true } },
},
});
console.log("schedule:", JSON.stringify(schedule));
// Update: move the booking to another service and leave a comment
const updated = await prisma.appointment.update({
where: { id: client.appointments[0].id },
data: { serviceId: manicure.id, comment: "Client asked to switch" },
});
console.log("updated:", updated.serviceId, updated.comment, updated.status);
// Delete: Restrict on the relation does not let us delete a service with bookings
try {
await prisma.service.delete({ where: { id: manicure.id } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
console.log("delete service:", e.code); // P2003 — foreign key constraint
} else throw e;
}
// Cascade: deleting a client deletes their bookings (onDelete: Cascade)
await prisma.client.delete({ where: { id: client.id } });
console.log("bookings left:", await prisma.appointment.count());
await prisma.$disconnect();$ npx tsx src/crud.ts
created: Aigerim bookings: 1
schedule: [{"id":1,"startsAt":"2026-10-20T05:00:00.000Z","status":"BOOKED","client":{"name":"Aigerim"},"service":{"title":"Haircut","priceKzt":8000}}]
updated: 2 Client asked to switch BOOKED
delete service: P2003
bookings left: 0- The nested write creates the client and the appointment in one transaction — per the docs, nested writes are atomic.
includeadds related records in full,selectpicks only the fields you need, including inside relations. You can't combine them on the same level.- Database errors arrive as
PrismaClientKnownRequestErrorwith a code:P2002— unique constraint failed,P2003— foreign key,P2025— a required record wasn't found.
You need a transaction when several changes only make sense together. Rescheduling is the classic case: cancel the old appointment and create a new one. If the new time is taken, the cancellation must not stick. An interactive transaction, $transaction(async (tx) => …), receives a tx client bound to the transaction; any exception inside rolls everything back.
import { Prisma } from "./generated/prisma/client";
import { prisma } from "./db";
// Move a booking: cancel the old one and create a new one — both or neither
async function reschedule(appointmentId: number, newStartsAt: Date) {
return prisma.$transaction(async (tx) => {
const old = await tx.appointment.update({
where: { id: appointmentId, status: "BOOKED" },
data: { status: "CANCELLED" },
});
// If the slot is taken, create throws P2002 and the update above is rolled back
return tx.appointment.create({
data: { clientId: old.clientId, serviceId: old.serviceId, startsAt: newStartsAt },
});
});
}
const booking = await prisma.appointment.findFirstOrThrow({
where: { status: "BOOKED" },
orderBy: { id: "asc" },
});
const busy = await prisma.appointment.findFirstOrThrow({
where: { serviceId: booking.serviceId, id: { not: booking.id } },
});
try {
await reschedule(booking.id, busy.startsAt); // slot is taken by another client
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
console.log("slot is taken, transaction rolled back");
} else throw e;
}
const after = await prisma.appointment.findUniqueOrThrow({ where: { id: booking.id } });
console.log(`booking ${booking.id} status: ${after.status}`);
const moved = await reschedule(booking.id, new Date("2026-10-25T06:00:00Z"));
console.log(`new booking ${moved.id} at ${moved.startsAt.toISOString()}`);
const old = await prisma.appointment.findUniqueOrThrow({ where: { id: booking.id } });
console.log(`old booking ${booking.id} status: ${old.status}`);
await prisma.$disconnect();$ npx tsx src/seed.ts
clients: 20 bookings: 40
$ npx tsx src/reschedule.ts
slot is taken, transaction rolled back
booking 3 status: BOOKED
new booking 43 at 2026-10-25T06:00:00.000Z
old booking 3 status: CANCELLEDDefaults from the docs: timeout is 5000 ms for the whole transaction, maxWait is 2000 ms to start it, and the isolation level is whatever the database uses (Read Committed for PostgreSQL). The docs recommend keeping transactions short and avoiding network calls and slow queries inside them: long transactions hurt performance and can cause deadlocks. The status: "BOOKED" condition in update prevents a double cancellation: if the appointment is already cancelled, Prisma throws P2025.
N+1: how to see it and fix it
N+1 is when code makes one query for a list and then one more query per item. With 20 clients that's 21 queries; with 2,000 it's 2,001, each a network round trip to the database. In code it looks harmless: an await inside a loop. Let's fill the database with test data and compare two approaches, counting SQL with the query event from src/db.ts.
import { prisma } from "./db";
// Test data: 3 services, 20 clients, two bookings each (fake phone numbers)
await prisma.appointment.deleteMany();
await prisma.client.deleteMany();
await prisma.service.deleteMany();
const services = await prisma.service.createManyAndReturn({
data: [
{ title: "Haircut", durationMin: 60, priceKzt: 8000 },
{ title: "Manicure", durationMin: 90, priceKzt: 10000 },
{ title: "Massage", durationMin: 60, priceKzt: 15000 },
],
});
for (let i = 1; i <= 20; i++) {
await prisma.client.create({
data: {
name: `Client ${i}`,
phone: `+7701000${String(i).padStart(4, "0")}`,
appointments: {
create: [0, 1].map((k) => ({
serviceId: services[(i + k) % services.length].id,
startsAt: new Date(Date.UTC(2026, 9, 20 + k, 4 + (i % 8), 0)),
status: k === 0 ? "DONE" : "BOOKED",
})),
},
},
});
}
console.log("clients:", await prisma.client.count(), "bookings:", await prisma.appointment.count());
await prisma.$disconnect();import { prisma, sql } from "./db";
// Bad: 1 query for clients + 1 query per client = N+1
sql.count = 0;
const clients = await prisma.client.findMany({ orderBy: { id: "asc" } });
let total = 0;
for (const c of clients) {
const bookings = await prisma.appointment.findMany({ where: { clientId: c.id } });
total += bookings.length;
}
console.log(`loop: ${clients.length} clients, ${total} bookings, ${sql.count} SQL queries`);
// Good: include loads the relation for all clients at once
sql.count = 0;
sql.print = true;
const withBookings = await prisma.client.findMany({
orderBy: { id: "asc" },
include: { appointments: true },
});
sql.print = false;
const total2 = withBookings.reduce((n, c) => n + c.appointments.length, 0);
console.log(`include: ${withBookings.length} clients, ${total2} bookings, ${sql.count} SQL queries`);
await prisma.$disconnect();$ npx tsx src/n-plus-one.ts
loop: 20 clients, 40 bookings, 21 SQL queries
SQL: SELECT "public"."clients"."id", "public"."clients"."name", "public"."clients"."phone", "public"."clients"."created_at" FROM "public"."clients" WHERE 1=1 ORDER BY "public"."clients"."id" ASC OFFSET $1
SQL: SELECT "public"."appointments"."id", "public"."appointments"."starts_at", "public"."appointments"."client_id", "public"."appointments"."service_id", "public"."appointments"."status"::text, "public"."appointments"."comment" FROM "public"."appointments" WHERE "public"."appointments"."client_id" IN ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) OFFSET $21
include: 20 clients, 40 bookings, 2 SQL queriesWith include, Prisma 7 by default runs one query per table and assembles the objects in the application — the docs call this the query strategy. The join strategy runs a single query with a LATERAL JOIN and JSON aggregation in PostgreSQL, but in Prisma 7 it is still a preview feature enabled with the relationJoins flag on the generator. For most screens, two queries instead of N+1 is already a good result.
- Count queries in development: the
queryevent orlog: ["query"]reveals a loop immediately - Look for
await prisma…insidefor,mapand GraphQL resolvers - Load relations with
includeor a nestedselect, not with separate calls - Use
_countfor counters instead of loading every related row - Select only the fields you need with
select— less data over the network
findUnique is a special case: per the docs, Prisma automatically batches such calls into one query when they arrive in the same tick of the event loop; findMany is not batched. How to read the execution plan of a slow query is covered in the EXPLAIN lesson.
When to write raw SQL, and what exists besides Prisma
The Prisma docs give two reasons for raw SQL: a heavily optimized query and a database feature the client API doesn't support yet. In practice that means reports with aggregates across tables, window functions, CTEs, full-text search and PostgreSQL-specific types. Example — revenue by service: Prisma's groupBy can't sum a price from a related table, while in SQL it's a single query.
import { prisma } from "./db";
type Row = { title: string; visits: bigint; revenue: bigint };
// Revenue by service: an aggregate over a JOIN is easier and clearer in SQL
const from = new Date("2026-10-01T00:00:00Z");
const rows = await prisma.$queryRaw<Row[]>`
SELECT s.title, count(*) AS visits, sum(s.price_kzt) AS revenue
FROM appointments a
JOIN services s ON s.id = a.service_id
WHERE a.status = 'DONE' AND a.starts_at >= ${from}
GROUP BY s.title
ORDER BY revenue DESC`;
// count() and sum() over integer in PostgreSQL return bigint → BigInt in JS
for (const r of rows) {
console.log(`${r.title}: ${r.visits} visits, ${Number(r.revenue).toLocaleString("en-US")} KZT`);
}
console.log(typeof rows[0].visits);
await prisma.$disconnect();$ npx tsx src/report.ts
Massage: 7 visits, 105,000 KZT
Manicure: 7 visits, 70,000 KZT
Haircut: 6 visits, 48,000 KZT
bigint$queryRaw is a tagged template: every ${…} becomes a prepared statement parameter, so a value can't change the SQL itself. Only values can be substituted this way — not table names, column names or keywords. Results are typed by the mapping table: 64-bit integers become BigInt, numeric becomes Decimal, dates become Date. For type-safe SQL, Prisma recommends TypedSQL: queries live in .sql files under prisma/sql, and prisma generate --sql creates typed functions for them; in Prisma 7 it's a preview feature (typedSql).
Where the schema lives
- Prisma 7.10
- a separate
schema.prismafile - Drizzle ORM 0.45
- TypeScript code (
pgTable) - TypeORM 1.1
- classes with
@Entity,@Columndecorators
Query style
- Prisma 7.10
- object-based client API
- Drizzle ORM 0.45
- SQL-like builder plus a relational API
- TypeORM 1.1
- repositories (Data Mapper) or Active Record
Migrations
- Prisma 7.10
- Prisma Migrate, SQL files
- Drizzle ORM 0.45
- migration generation and apply
- TypeORM 1.1
- automatic migration generation
Good fit
- Prisma 7.10
- routine CRUD, strict types, convenient relations
- Drizzle ORM 0.45
- teams that want near-SQL with types
- TypeORM 1.1
- entity-class projects, including NestJS ones
Versions in the table are the current npm releases on the check date. Choosing an ORM is an engineering trade-off, not a question of better or worse: in our experience, agreeing as a team on migration rules and on where raw SQL is acceptable matters more than picking the "right" library.
Migrations in a team and in production, errors and exercises
prisma/migrationsandmigration_lock.tomlgo into Git; the generated client doesn't —prisma generatecreates it during the build- Never edit an applied migration; fix things with a new migration. Do not rely on a
migrate deploywarning: the docs describe one, but in Prisma 7.10 an edited applied migration went through silently (verified in the lesson on boundaries and migrations in an AI project) — compare checksums in CI - Servers run only
migrate deployfrom CI/CD;migrate devandmigrate resetare development commands - Review every generated SQL file: a drop instead of a rename only shows up there
- Renames and data changes go through
--create-onlyand a hand-edited SQL file - A required column on a table with data takes several steps: add it nullable, backfill, then
SET NOT NULL - Before a production migration: a backup and a dry run on a copy of the data
npm install prisma installed 8.0.0-rc.…
- Cause
- the CLI's
latesttag points to the version 8 RC - Fix
- install
prisma@7and@prisma/client@7, pin^7inpackage.json
./generated/prisma/client can't be resolved after cloning
- Cause
- the client is generated into the project and not stored in Git;
migrate devin Prisma 7 doesn't create it - Fix
npx prisma generatelocally and in the build
The CLI or the app can't see DATABASE_URL
- Cause
- Prisma 7 doesn't load
.envitself - Fix
import "dotenv/config"in the config and the entry point
"Drift detected" and an offer to reset the database
- Cause
- the schema was changed by hand, bypassing migrations
- Fix
- in development —
migrate reset; turn the change into a migration
"…environment is non-interactive, which is not supported"
- Cause
- the change has warnings and
migrate devneeds confirmation in a terminal - Fix
- run it in an interactive terminal; in CI use only
migrate deploy
"Added the required column … without a default value"
- Cause
- a new required column on a table with rows
- Fix
--create-onlyand a hand-written migration with a backfill
P2002 / P2003
- Cause
- unique constraint / foreign key violation
- Fix
- handle as a data conflict (409) or a validation error
We hit the last-but-one error ourselves on a database with 40 appointments — this is the real Prisma 7.10 message:
Error:
⚠️ We found changes that cannot be executed:
• Step 0 Added the required column `price_kzt` to the `appointments` table without a default value. There are 40 rows in this table, it is not possible to execute this step.
You can use prisma migrate dev --create-only to create the migration file, and manually modify it to address the underlying issue(s).
Then run prisma migrate dev to apply it and verify it works.How this was verified4 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.