Writing SQL by hand is fine until a schema change touches twenty queries. An ORM maps tables to typed objects, generates the SQL for you and keeps relations consistent. Prisma is the most type-safe option in the Node ecosystem.
Learning objectives
- Describe models in
schema.prisma. - Run migrations and generate the client.
- Create, read, update and delete records with relations.
- Choose
includeandselectdeliberately.
1. The three pieces
schema.prisma → migration → database
│
└────────→ Prisma Client (typed API in Node)
npm install -D prisma
npm install @prisma/client
npx prisma init --datasource-provider sqlite
That creates prisma/schema.prisma and adds DATABASE_URL="file:./dev.db" to .env.
2. Models and relations
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
bio String?
posts Post[]
comments Comment[]
createdAt DateTime @default(now())
@@map("users")
}
model Post {
id Int @id @default(autoincrement())
title String
content String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId Int
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("posts")
}
model Comment {
id Int @id @default(autoincrement())
content String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
postId Int
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId Int
createdAt DateTime @default(now())
@@map("comments")
}
String? is nullable; String is required. @unique creates a unique index. @default(now()) and @updatedAt handle timestamps. onDelete: Cascade deletes a post's comments with the post.
3. Migrate
npx prisma migrate dev --name add-comments
npx prisma generate
migrate dev writes a SQL migration, applies it and regenerates the client. prisma studio opens a browser GUI for the data.
4. The client
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
Create one client per process, not per request.
// create with a relation
const post = await prisma.post.create({
data: {
title: "Hello",
content: "First post",
author: { connect: { id: 1 } },
},
});
// list with relation data
const posts = await prisma.post.findMany({
where: { published: true },
include: { author: { select: { name: true } }, _count: { select: { comments: true } } },
orderBy: { createdAt: "desc" },
skip: 0,
take: 10,
});
// update / delete
await prisma.post.update({ where: { id: post.id }, data: { published: true } });
await prisma.post.delete({ where: { id: post.id } });
include returns full related records; select returns only the fields you name. select is how you avoid leaking password hashes and oversized content.
5. Errors
Prisma throws typed errors for common cases:
const { Prisma } = require("@prisma/client");
try {
await prisma.user.create({ data: { email: "[email protected]", name: "A" } });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return res.status(409).json({ error: "Email already exists" });
}
throw error;
}
P2002 unique violation, P2025 record not found, P2003 foreign key failure.
6. When not to use an ORM
For bulk imports, complex analytics or hand-tuned queries, raw SQL through prisma.$queryRaw is faster and clearer. Use the ORM for CRUD and relations; drop to SQL for reports.
Common mistakes
- Editing the database by hand and expecting Prisma to know — change the schema and migrate.
- Forgetting
awaiton a Prisma call (it returns a Promise). - Creating a new
PrismaClientinside a request handler. - Using
includeeverywhere and returning whole object graphs to the client.
Practice
Add comments to the blog schema and build the nested API in the assignment.