DEV Community

Cover image for Prisma Deep‑Dive Handbook (2025) — From Zero to Expert
Mihir Bhadak
Mihir Bhadak

Posted on

Prisma Deep‑Dive Handbook (2025) — From Zero to Expert

A practical, end‑to‑end guide for expert developers who are new to Prisma. Covers modeling, migrations, querying, performance, deployment, testing, and gotchas — with copy‑pasteable snippets.


0) What Prisma is (and isn’t)

Prisma is a modern TypeScript ORM + tooling that gives you:

  • Prisma Client – a type‑safe query builder generated from your schema
  • Prisma Migrate – schema‑driven migrations and drift detection
  • Prisma Studio – a visual data browser
  • Optional extras – e.g. Accelerate (global connection pooling / edge), adapters for serverless/edge runtimes

It is not a query language of its own (it compiles to SQL or Mongo queries under the hood) and it’s not a data layer framework like GraphQL.

Supported databases include PostgreSQL, MySQL / MariaDB, SQLite, SQL Server, CockroachDB, and MongoDB (special connector with a few differences).


1) Quickstart (greenfield)

# 1) Install
npm i -D prisma
npm i @prisma/client

# 2) Bootstrap project files
npx prisma init
# creates: prisma/schema.prisma and .env

# 3) Model your data in prisma/schema.prisma (see §2–§4)

# 4) Create your first migration & generate client
npx prisma migrate dev --name init
# (re)generates Prisma Client too

# 5) Open a data GUI
npx prisma studio
Enter fullscreen mode Exit fullscreen mode

Create and use a client instance:

// src/db.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({ log: ["warn", "error"] });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Enter fullscreen mode Exit fullscreen mode

2) Anatomy of schema.prisma

// prisma/schema.prisma

datasource db {
  provider = "postgresql"      // mysql | sqlite | sqlserver | mongodb | cockroachdb
  url      = env("DATABASE_URL")
  // For advanced setups you can add:
  // directUrl          = env("DIRECT_URL")
  // shadowDatabaseUrl  = env("SHADOW_DATABASE_URL")
}

generator client {
  provider        = "prisma-client-js"
  // previewFeatures = ["..."]      // opt-in features (only when needed)
}

model User {
  id        Int       @id @default(autoincrement())
  createdAt DateTime  @default(now())
  email     String    @unique
  name      String?
  role      Role      @default(USER)
  posts     Post[]
  // Map to existing DB column names if needed:
  // @map("user_name")  and @@map("tbl_user") on the model
}

model Post {
  id        Int      @id @default(autoincrement())
  slug      String   @unique
  title     String
  content   String?
  published Boolean  @default(false)
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  tags      Tag[]    // implicit many-to-many via join table `_PostToTag`

  @@index([authorId])
  @@fulltext([title, content])   // Postgres/MySQL where supported
}

enum Role {
  USER
  ADMIN
}

model Tag {
  id   Int     @id @default(autoincrement())
  name String  @unique
  posts Post[]
}
Enter fullscreen mode Exit fullscreen mode

Sections

  • datasource: DB type + connection URL(s)
  • generator: which client(s) to generate (JS is default); can add extras via client extensions
  • Data model: your tables/collections; relations, indexes, defaults, enums, etc.

Common scalar types: String, Int, BigInt, Float, Decimal, Boolean, DateTime, Bytes, Json (+ MongoDB’s ObjectId via @db.ObjectId).

Attributes you’ll use a lot

  • IDs: @id, composite @@id([a,b]), @default(autoincrement()) | cuid() | uuid()
  • Uniqueness & indexes: @unique, @@unique([a,b]), @@index([...]), @@fulltext([...])
  • Relations: @relation(fields: [fk], references: [pk], onDelete: Cascade|Restrict|SetNull|NoAction, onUpdate: ...)
  • Mapping: @map("col"), @@map("table") to bind to legacy names

3) Relations by example

1‑to‑Many

model User { id Int @id @default(autoincrement()) posts Post[] }
model Post { id Int @id @default(autoincrement()) authorId Int author User @relation(fields: [authorId], references: [id]) }
Enter fullscreen mode Exit fullscreen mode

1‑to‑1 (required on both sides)

model User  { id Int @id @default(autoincrement()) profile Profile? }
model Profile { id Int @id @default(autoincrement()) userId Int @unique user User @relation(fields: [userId], references: [id]) }
Enter fullscreen mode Exit fullscreen mode

Many‑to‑Many (implicit)

model Post { id Int @id @default(autoincrement()) tags Tag[] }
model Tag  { id Int @id @default(autoincrement()) posts Post[] }
Enter fullscreen mode Exit fullscreen mode

Prisma creates a hidden join table _PostToTag. Use an explicit join model if you need extra columns (e.g., assignedAt, weight).

Referential actions

@relation(onDelete: Cascade)  // delete children when parent is deleted
@relation(onDelete: SetNull)  // set FK to NULL
Enter fullscreen mode Exit fullscreen mode

4) Migrations: the definitive workflow

Local/dev

# Create a migration from schema changes, apply it, and regen client
npx prisma migrate dev --name add_posts

# Reset database and reapply all migrations (DANGER: data loss)
npx prisma migrate reset
Enter fullscreen mode Exit fullscreen mode

Deploy/CI

# In pipelines, only apply checked-in migrations
npx prisma migrate deploy
Enter fullscreen mode Exit fullscreen mode

When to use db push (no migration files)

npx prisma db push   # quick prototyping or sync to a dev DB without versioned SQL
Enter fullscreen mode Exit fullscreen mode

Prefer migrate for team/CI. Use db push for prototypes or MongoDB.

Folders/files

  • prisma/migrations/2025xxxx_add_posts/ with SQL + migration.sql
  • _prisma_migrations table records history
  • Shadow DB (optional) is used to safely diff schemas

Fixing mistakes

  • If a migration failed in prod, create a follow‑up migration (never edit an already‑deployed migration)
  • For complex drift, generate SQL with prisma migrate diff and apply manually

5) Introspection (brownfield)

Already have a DB? Generate a schema from it:

npx prisma db pull
Enter fullscreen mode Exit fullscreen mode

Then you can start querying immediately. Going forward, choose either migrations or keep pulling and treat the DB as the source of truth (avoid mixing both in the same branch).


6) Seeding

// package.json
{
  "prisma": { "seed": "ts-node prisma/seed.ts" }
}
Enter fullscreen mode Exit fullscreen mode
// prisma/seed.ts
import { prisma } from "../src/db";

async function run() {
  await prisma.user.create({
    data: {
      email: "admin@example.com",
      role: "ADMIN",
      posts: { create: { title: "Hello Prisma", slug: "hello-prisma" } },
    },
  });
}
run().finally(() => prisma.$disconnect());
Enter fullscreen mode Exit fullscreen mode
npx prisma db seed
Enter fullscreen mode Exit fullscreen mode

7) Querying with Prisma Client (the 95% you use daily)

CRUD basics

// Create
await prisma.post.create({ data: { title: "T1", slug: "t1", authorId: 1 } });

// Read
const post = await prisma.post.findUnique({ where: { id: 1 } });

// Update
await prisma.post.update({ where: { id: 1 }, data: { published: true } });

// Delete
await prisma.post.delete({ where: { id: 1 } });
Enter fullscreen mode Exit fullscreen mode

Filtering, selecting, including

const posts = await prisma.post.findMany({
  where: {
    published: true,
    title: { contains: "prisma", mode: "insensitive" },
    author: { email: { endsWith: "@example.com" } },
  },
  select: { id: true, title: true, author: { select: { id: true, email: true } } },
  orderBy: { createdAt: "desc" },
  take: 20,
  skip: 0,
});
Enter fullscreen mode Exit fullscreen mode

Nested writes

// Create a post and connect to (or create) a tag in one go
await prisma.post.create({
  data: {
    title: "Nested",
    slug: "nested",
    author: { connect: { id: 1 } },
    tags: {
      connectOrCreate: [{ where: { name: "ts" }, create: { name: "ts" } }],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Upsert & bulk ops

await prisma.user.upsert({
  where: { email: "a@b.com" },
  create: { email: "a@b.com" },
  update: { name: "Alice" },
});

await prisma.post.createMany({
  data: [{ title: "a", slug: "a", authorId: 1 }, { title: "b", slug: "b", authorId: 1 }],
  skipDuplicates: true,
});
Enter fullscreen mode Exit fullscreen mode

Aggregation & grouping

const count = await prisma.post.count({ where: { published: true } });
const stats = await prisma.post.aggregate({ _avg: { id: true }, _max: { createdAt: true } });
const byAuthor = await prisma.post.groupBy({ by: ["authorId"], _count: true, where: { published: true } });
Enter fullscreen mode Exit fullscreen mode

Cursor pagination

const page = await prisma.post.findMany({
  cursor: { id: lastSeenId },
  skip: 1,
  take: 20,
  orderBy: { id: "asc" },
});
Enter fullscreen mode Exit fullscreen mode

Transactions

// Batched (runs in a single transaction)
await prisma.$transaction([
  prisma.user.create({ data: { email: "x@y.com" } }),
  prisma.post.create({ data: { title: "T", slug: "t", authorId: 1 } }),
]);

// Interactive transaction
await prisma.$transaction(async (tx) => {
  const u = await tx.user.create({ data: { email: "z@z.com" } });
  await tx.profile.create({ data: { userId: u.id } });
});
Enter fullscreen mode Exit fullscreen mode

Raw SQL (only when you must)

import { Prisma } from "@prisma/client";

const rows = await prisma.$queryRaw<{ id: number }[]>`
  SELECT id FROM "Post" WHERE title ILIKE ${"%prisma%"}
`;
Enter fullscreen mode Exit fullscreen mode

Always pass values as template parameters (not string‑interpolated) to avoid SQL injection.


8) Middleware & Client Extensions

Middleware ($use)

prisma.$use(async (params, next) => {
  const started = Date.now();
  const result = await next(params);
  const took = Date.now() - started;
  if (took > 200) console.warn(`[slow] ${params.model}.${params.action} in ${took}ms`);
  return result;
});
Enter fullscreen mode Exit fullscreen mode

Client extensions ($extends)

Add reusable helpers or computed fields.

import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient().$extends({
  model: {
    post: {
      async publish(id: number) {
        return prisma.post.update({ where: { id }, data: { published: true } });
      },
    },
  },
  result: {
    post: {
      preview: {
        needs: { content: true },
        compute(p) { return (p.content ?? "").slice(0, 120); },
      },
    },
  },
});

await prisma.post.publish(1);
Enter fullscreen mode Exit fullscreen mode

9) Deployment patterns & connection management

Long‑running servers (Express/Nest/Fastify)

  • Create one PrismaClient per process and reuse it
  • Enable logs in dev, disable chatty logs in prod

Serverless (Vercel/Netlify/Lambda)

  • Use a global singleton pattern to avoid opening a new pool per cold start
  • Prefer a connection pooler (e.g. PgBouncer) or a serverless‑friendly provider (Neon, PlanetScale, Supabase pooler)
  • Consider Prisma Accelerate or the Edge client when running at the edge

Edge runtimes

  • Use @prisma/client/edge plus an adapter/Accelerate; keep queries lean and avoid long transactions

Helpful env vars

  • DATABASE_URL – main connection string
  • DIRECT_URL – bypass pooler for migrations if needed
  • SHADOW_DATABASE_URL – migration shadow DB

10) Performance checklist

  • Modeling: add @@index, composite indexes for hot filters; avoid SELECT * (use select)
  • N+1: load relations via include or do two queries with IDs and where: { id: { in: ids } }
  • Bulk ops: prefer createMany, updateMany, deleteMany where applicable
  • Pagination: use cursor pagination for infinite scroll
  • Pooling: configure poolers; avoid creating clients in hot code paths
  • Tracing/metrics: enable OpenTelemetry or built‑in logging in non‑prod to find slow spots

11) Testing strategies

  • Run migrations against a throwaway DB per test run (Docker or file‑based SQLite)
  • Reset via prisma migrate reset (or wrap tests in a top‑level transaction and roll back)
  • Provide factories using Prisma Client (and keep them fast)

12) Multi‑tenancy options

  • Row‑level (tenantId column + RLS in Postgres) — simplest operationally
  • Schema‑per‑tenant (Postgres) — isolation at schema level; more complex migrations
  • Database‑per‑tenant — strongest isolation but operational overhead

13) Security notes

  • Never interpolate raw SQL — always parameterize with $queryRaw template args
  • Enforce authorization in code (Prisma does not do auth) and/or DB policies (e.g., Postgres RLS)
  • Validate inputs (Zod/Valibot) before querying
  • Keep migrations in VCS; track who can run migrate deploy

14) CLI command reference (most used)

# Project
npx prisma init                 # bootstrap schema + .env
npx prisma generate             # regenerate Prisma Client
npx prisma format               # format schema.prisma
npx prisma validate             # schema validation

# Database workflows
npx prisma db pull              # introspect existing DB into schema
npx prisma db push              # apply schema to DB (no migration files)

# Migrations (preferred for teams/CI)
npx prisma migrate dev --name <msg>   # create & apply a new migration (dev)
npx prisma migrate reset              # drop DB & reapply migrations (danger)
npx prisma migrate deploy             # apply checked-in migrations (CI/prod)
npx prisma migrate diff \             # generate SQL diff between two states
  --from-url "$A" --to-url "$B" --script
npx prisma migrate resolve            # mark a migration as applied/rolled back

# Tooling
npx prisma studio               # open GUI to view/edit data

# Options (work with any command)
--schema ./path/to/schema.prisma
--skip-generate
--skip-seed
--preview-feature               # (only if a doc asks for it)
Enter fullscreen mode Exit fullscreen mode

15) Next.js patterns (App Router)

Single client instance

// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Enter fullscreen mode Exit fullscreen mode

Route handler example

// app/api/posts/route.ts
import { prisma } from "@/src/lib/prisma";
export async function GET() {
  const posts = await prisma.post.findMany({ take: 50, orderBy: { id: "desc" } });
  return Response.json(posts);
}
Enter fullscreen mode Exit fullscreen mode

Edge (optional)

import { PrismaClient } from "@prisma/client/edge";
import { withAccelerate } from "@prisma/extension-accelerate";
export const runtime = "edge";
export async function GET() {
  const prisma = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL })
    .$extends(withAccelerate());
  const users = await prisma.user.findMany({ take: 10 });
  return Response.json(users);
}
Enter fullscreen mode Exit fullscreen mode

16) Patterns & recipes

Soft delete

model Post {
  id Int @id @default(autoincrement())
  deletedAt DateTime?
}
Enter fullscreen mode Exit fullscreen mode
prisma.$use(async (params, next) => {
  if (params.model === "Post" && ["delete", "deleteMany"].includes(params.action)) {
    params.action = params.action === "delete" ? "update" : "updateMany";
    // @ts-ignore add data for soft delete
    params.args.data = { ...(params.args.data ?? {}), deletedAt: new Date() };
  }
  return next(params);
});
Enter fullscreen mode Exit fullscreen mode

Cursor‑based pagination helper (client extension)

const prisma = new PrismaClient().$extends({
  model: {
    post: {
      async page({ after, size = 20 }: { after?: number; size?: number }) {
        return prisma.post.findMany({
          take: size,
          ...(after ? { cursor: { id: after }, skip: 1 } : {}),
          orderBy: { id: "asc" },
        });
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Zod integration for inputs

import { z } from "zod";
const CreatePost = z.object({ title: z.string().min(3), slug: z.string() });
export type CreatePost = z.infer<typeof CreatePost>;
Enter fullscreen mode Exit fullscreen mode

17) Troubleshooting & common errors

  • P2002 (Unique constraint failed): handle with application error mapping; guard with upsert or pre‑checks
  • P2021/P2022 (Table/column not found): run missing migrations or db push
  • Too many connections: reuse a singleton client; use poolers (PgBouncer) or Accelerate/edge client; avoid creating client per request
  • Shadow database errors: set SHADOW_DATABASE_URL; ensure user can create/drop schemas
  • Mongo connector differences: no joins; relations are manual via ids; many Prisma features still work with adjusted semantics

18) Mental models & best practices

  • Treat schema.prisma as the source of truth for relational DBs in app‑owned schemas
  • Prefer migrate over db push for teams and CI/CD
  • Always check in migration folders; never edit a deployed migration
  • Keep client usage close to your data layer; avoid passing Prisma Client deep into UI code
  • Scope select/include narrowly to reduce payloads and tighten types
  • For high‑scale read paths, consider an app‑level cache and/or read replica

Appendices

Type helpers you’ll actually use

import type { Prisma } from "@prisma/client";

// Derive the argument type of a query
type FindManyPostArgs = Prisma.PostFindManyArgs;

// Result type of a query
type PostPayload = Prisma.PostGetPayload<{ select: { id: true; title: true } }>;
Enter fullscreen mode Exit fullscreen mode

Environment examples

# .env
DATABASE_URL="postgresql://user:pass@host:5432/db?schema=public&connection_limit=5&pool_timeout=30"
DIRECT_URL="postgresql://user:pass@writer-host:5432/db?schema=public"  # for migrations
SHADOW_DATABASE_URL="postgresql://user:pass@host:5432/shadow"
Enter fullscreen mode Exit fullscreen mode

MySQL ↔ Postgres gotchas

  • @default(autoincrement()) works on both; UUIDs are often better for sharding
  • Full‑text search: native on MySQL & Postgres (use @@fulltext) but syntax differs under the hood
  • Json type exists on both; prefer structured columns when you can

Mongo specifics

  • Use @db.ObjectId on id fields; relations are manual (no joins)
  • Many relational helpers (include, select, filters) still work but semantics differ

19) Quick cheat sheet

  • Modeling: @id, @default(), @unique, @relation(...), @@index([...])
  • Migrate: migrate dev, migrate deploy, migrate reset, avoid editing old migrations
  • Query: findMany, findUnique, create, update, delete, upsert, createMany
  • Filters: { contains, startsWith, in, notIn, lt, lte, gt, gte, equals, mode: "insensitive" }
  • Relations: { include: { rel: true } } or { select: { rel: { select: { ... } } } }
  • Pagination: { take, skip, cursor, orderBy }
  • Transactions: prisma.$transaction([...]) or prisma.$transaction(async tx => { ... })
  • Diagnostics: Prisma Studio, logs, OpenTelemetry tracing

🚀 Wrapping Up

Prisma ORM is a game-changer for modern web development, enabling developers to work seamlessly with databases while enjoying type safety and an excellent developer experience.

Whether you’re building with Next.js, scaling applications with Node.js, writing strongly-typed code in TypeScript, or integrating Prisma with React on the frontend, you’ll find Prisma fits naturally into your workflow.

Prisma also supports multiple databases like PostgreSQL, MySQL, MongoDB, and SQLite, making it the perfect companion for projects of all sizes.

By combining Prisma with tools like Supabase for serverless hosting, or using it in production with Postgres, you unlock massive flexibility and reliability for modern full-stack apps.


📚 Recommended Resources


Additional SEO Resources

  • Learn how to integrate Prisma ORM with Next.js, Node.js, and TypeScript for scalable applications.
  • Best practices for using Prisma with PostgreSQL, MySQL, and MongoDB.
  • How Prisma improves developer experience when building React and Next.js full-stack apps.
  • A step-by-step guide to connect Prisma ORM with modern databases like Postgres and MongoDB.
  • Using Prisma with Supabase, Vercel, and serverless environments.

Top comments (0)