How to buildOctober 11, 202613 min read
DatabasesStep 8 of 12

ORM and migrations with Prisma

In short

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.

TutorialLevel: IntermediateORM & MigrationsPractice: ~1.5–3 h

After this you will be able to

  • describe models and relations in schema.prisma and read the SQL that prisma migrate dev generates 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

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
  1. 01ORM: what it gives you and what it costs
  2. 02Project: Prisma 7.10 and PostgreSQL 17 in Docker
  3. 03Schema: models, relations and database names
  4. 04Migrations: migrate dev, SQL and history
  5. 05Queries: CRUD, relations and a transaction
  6. 06N+1: how to see it and fix it
  7. 07When to write raw SQL, and what exists besides Prisma
  8. 08Migrations 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.

bash
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-skills
A PostgreSQL 17 container, an ES module project and Prisma 7.10 with pinned versions

Prisma 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 in node_modules
Prisma 7
prisma-client with a required output; import from ./generated/prisma/client

Connection string

Prisma 6
url in the schema's datasource block
Prisma 7
in the Prisma config file (prisma.config.ts or prisma7.config.ts)

Database connection

Prisma 6
built-in query engine
Prisma 7
a driver adapter is required; for PostgreSQL — @prisma/adapter-pg on top of pg

.env file

Prisma 6
loaded automatically
Prisma 7
not loaded: add import "dotenv/config"

migrate dev

Prisma 6
ran generate and seeding itself
Prisma 7
doesn't: run prisma generate and prisma db seed separately

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.

prisma7.config.ts
// 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"],
  },
});
The config file exactly as prisma init 7.10 created it: dotenv is loaded explicitly
.env
DATABASE_URL="postgresql://postgres:test@localhost:55817/booking"
Connection string for the lesson container; init has already added .env to .gitignore
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "noEmit": true,
    "types": ["node"]
  },
  "include": ["src", "prisma7.config.ts"]
}
TypeScript settings for ES modules, as the Prisma 7 guide recommends
src/db.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}`);
});
One client instance per application: the node-postgres adapter and a query event for counting SQL

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:

prisma/schema.prisma
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")
}
First version of the schema: three models, two relations, unique constraints and an index

@id @default(autoincrement())

What you get in PostgreSQL
a SERIAL column 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 CASCADE or RESTRICT

@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 default TIMESTAMP(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.

terminal
$ 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                          │
└─────────────────────────────────────────────────────────┘
Real output of the first migration; the CLI offers an "update" to 8.0.0-rc.22 — not needed for this lesson
prisma/migrations/20261012005404_init/migration.sql
-- 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;
Generated SQL of the first migration, unedited

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:

prisma/schema.prisma
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")
}
Schema fragment: a status field with an enum and a note field
prisma/migrations/20261012005422_add_status_and_note/migration.sql
-- 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';
SQL of the second migration: CREATE TYPE and two new columns; existing rows get status 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:

terminal
$ 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;
The generated SQL drops the column along with its data — Prisma warns about it in a comment
prisma/migrations/20261012005439_rename_note_to_comment/migration.sql
-- Rename instead of drop + add: keeps existing data
ALTER TABLE "appointments" RENAME COLUMN "note" TO "comment";
The edited migration: a rename instead of drop and add
terminal
$ 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 46ms
The migration is applied and the test note survived; Prisma 7 client regenerated by hand
Edit schema.prismamigrate devReview SQLCommit to Gitmigrate deploy
DevelopmentCI/CD
How a schema change travels from a model edit to the server

Servers 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:

terminal
$ 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.
All three migrations applied in order to a new booking_stage database

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:

terminal
$ 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.
A manual ALTER TABLE bypassing migrations: Prisma shows the difference and asks for a reset

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.

src/crud.ts
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();
CRUD with Prisma Client: create with a nested write, findMany with select across relations, update and delete
terminal
$ 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
Real output: 10:00 at +05:00 is stored as 05:00 UTC; deleting a service with appointments fails with P2003
  • The nested write creates the client and the appointment in one transaction — per the docs, nested writes are atomic.
  • include adds related records in full, select picks only the fields you need, including inside relations. You can't combine them on the same level.
  • Database errors arrive as PrismaClientKnownRequestError with 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.

src/reschedule.ts
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();
Rescheduling in an interactive transaction: P2002 on a taken slot rolls back the cancellation
terminal
$ 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: CANCELLED
The first attempt failed and rolled back — appointment 3 stayed BOOKED; the second went through

Defaults 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.

src/seed.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();
Test data: 3 services and 20 clients with two appointments each; phone numbers are fake
src/n-plus-one.ts
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();
A query per client in a loop versus include, with an SQL query counter
terminal
$ 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 queries
Real output: 21 queries in the loop and 2 with include; the second query fetches all clients' appointments with IN (…)

With 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 query event or log: ["query"] reveals a loop immediately
  • Look for await prisma… inside for, map and GraphQL resolvers
  • Load relations with include or a nested select, not with separate calls
  • Use _count for 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.

src/report.ts
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();
A report with $queryRaw: the from value is sent as a parameter, not glued into the string
terminal
$ npx tsx src/report.ts
Massage: 7 visits, 105,000 KZT
Manicure: 7 visits, 70,000 KZT
Haircut: 6 visits, 48,000 KZT
bigint
Real output: count and sum over integer in PostgreSQL return bigint, which is BigInt in JavaScript

$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.prisma file
Drizzle ORM 0.45
TypeScript code (pgTable)
TypeORM 1.1
classes with @Entity, @Column decorators

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/migrations and migration_lock.toml go into Git; the generated client doesn't — prisma generate creates it during the build
  • Never edit an applied migration; fix things with a new migration. Do not rely on a migrate deploy warning: 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 deploy from CI/CD; migrate dev and migrate reset are development commands
  • Review every generated SQL file: a drop instead of a rename only shows up there
  • Renames and data changes go through --create-only and 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 latest tag points to the version 8 RC
Fix
install prisma@7 and @prisma/client@7, pin ^7 in package.json

./generated/prisma/client can't be resolved after cloning

Cause
the client is generated into the project and not stored in Git; migrate dev in Prisma 7 doesn't create it
Fix
npx prisma generate locally and in the build

The CLI or the app can't see DATABASE_URL

Cause
Prisma 7 doesn't load .env itself
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 dev needs 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-only and 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:

terminal
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.
migrate dev refuses to add a required column without a default to a non-empty table
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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.