1. One-to-Many Relations
We'll extend last week's blog schema. A User can write many
Posts, and each Post belongs to exactly one
User — a classic one-to-many relation. In Prisma, you declare the
"many" side with a foreign key scalar field plus a @relation field, and
the "one" side with a plain array field:
model User {
id Int @id @default(autoincrement())
email String @unique
name String
posts Post[]
comments Comment[]
}
model Post {
id Int @id @default(autoincrement())
title String
content String
published Boolean @default(false)
featured Boolean @default(false)
commentCount Int @default(0)
authorId Int
author User @relation(fields: [authorId], references: [id])
comments Comment[]
tags Tag[] @relation("PostTags")
createdAt DateTime @default(now())
}
model Comment {
id Int @id @default(autoincrement())
body String
postId Int
post Post @relation(fields: [postId], references: [id])
authorId Int
author User @relation(fields: [authorId], references: [id])
createdAt DateTime @default(now())
}
authorId is a real column in the Post table — that's the
foreign key. author is not a column at all; it's a virtual field that
tells Prisma's client how to join back to User when you ask for it.
After running prisma migrate dev --name add-post-author, Prisma
generates a fully-typed client where author and posts exist
as optional relation fields on their respective types — TypeScript will refuse to let
you access post.author.name unless you actually asked Prisma to fetch it.
import { Router } from "express";
import { prisma } from "../lib/prisma.js";
export const postsRouter = Router();
postsRouter.get("/:id", async (req, res, next) => {
try {
const post = await prisma.post.findUnique({
where: { id: Number(req.params.id) },
include: { author: true }, // fetch the related User in the same query
});
if (!post) {
return res.status(404).json({ error: "Post not found" });
}
res.json(post);
} catch (err) {
next(err);
}
});
include, the relation just isn't there
A plain prisma.post.findUnique({ where: { id } }) returns a Post object with no author property at all — not null, simply absent, and TypeScript will flag post.author as an error at compile time. This is Prisma being deliberately explicit: relations only load when you ask, which is exactly the behavior that keeps Section 3's N+1 problem from happening by accident on every query.
2. Many-to-Many Relations
A Post can have many Tags, and a Tag ("node",
"typescript") can apply to many Posts — many-to-many. Prisma supports two
ways to model this. The simpler, implicit form just declares array
fields on both sides with a matching relation name; Prisma creates and manages the
join table for you behind the scenes:
model Tag {
id Int @id @default(autoincrement())
name String @unique
posts Post[] @relation("PostTags")
}
// Post.tags from Section 1 is the other half of this relation
postsRouter.post("/:id/tags", async (req, res, next) => {
try {
const post = await prisma.post.update({
where: { id: Number(req.params.id) },
data: {
tags: {
connect: [{ name: "typescript" }, { name: "prisma" }],
},
},
include: { tags: true },
});
res.json(post);
} catch (err) {
next(err);
}
});
connect attaches existing rows to the relation without creating new ones
— it's the equivalent of an INSERT into the hidden join table. Reach for
this implicit form whenever the relation itself carries no extra data. The moment you
need to store something about the relationship — when a student enrolled in a
course, what grade they got — you need an explicit join model
instead, because the implicit join table has no room for extra columns:
model Student {
id Int @id @default(autoincrement())
name String
enrollments Enrollment[]
}
model Course {
id Int @id @default(autoincrement())
title String
enrollments Enrollment[]
}
model Enrollment {
studentId Int
courseId Int
enrolledAt DateTime @default(now())
grade String?
student Student @relation(fields: [studentId], references: [id])
course Course @relation(fields: [courseId], references: [id])
@@id([studentId, courseId]) // composite primary key -- one row per enrollment
}
The implicit form is genuinely less code and Prisma manages the join table's migrations for you. Start there for a plain tagging relation like Post/Tag, and only reach for an explicit model like Enrollment once product requirements actually need a field living on the relationship itself.
3. The N+1 Problem
The N+1 problem is what happens when you fetch a list of N rows, then loop over that list and issue one additional query per row to fetch each row's related data — N+1 total queries where one would've done. It's easy to write by accident, and it's the single most common Prisma performance bug in real apps:
postsRouter.get("/", async (req, res, next) => {
try {
const posts = await prisma.post.findMany(); // 1 query
const withAuthors = await Promise.all(
posts.map(async (post) => {
// one extra query PER post -- this is the "+N"
const author = await prisma.user.findUnique({ where: { id: post.authorId } });
return { ...post, author };
}),
);
res.json(withAuthors); // 21 total queries for a page of 20 posts
} catch (err) {
next(err);
}
});
Promise.all here makes the extra queries run concurrently rather than
sequentially, which softens the latency hit but doesn't remove the underlying problem:
you're still round-tripping to the database 21 times for one page of results, and
connection-pool pressure scales with that count, not with wall-clock time. The fix is
to tell Prisma up front which relations you need, so it generates a single query — a
JOIN under the hood — instead of one query per row:
postsRouter.get("/", async (req, res, next) => {
try {
const posts = await prisma.post.findMany({
include: {
author: { select: { id: true, name: true } }, // only the fields the UI needs
tags: true,
},
orderBy: { createdAt: "desc" },
take: 20,
});
res.json(posts); // 1 query, full result set
} catch (err) {
next(err);
}
});
include pulls in an entire related model (or a nested select
within it, as shown for author here to trim unused columns).
select on the top-level query does the same trimming for the root model —
use it when you only need a handful of fields from Post itself and want
to avoid pulling content across the wire for a list view that doesn't
render it.
Instantiate Prisma with new PrismaClient({ log: ["query"] }) during development and watch the terminal while you hit your endpoints. Seeing "1 query" become "21 queries" the moment you add a naive relation loop is far more convincing than any explanation — and it's the same technique you'll use in Week 11 to spot slow endpoints before optimizing them.
4. Multi-Step Writes with $transaction
Some operations touch more than one row and need to succeed or fail together. Say only
one post on the whole blog can be featured at a time: featuring a new
post means un-featuring whichever post currently holds that spot and featuring the new
one. Do that as two separate, unguarded update calls and a second request
arriving between them can leave you with two featured posts, or zero:
// Request A and Request B, both firing at nearly the same time,
// can interleave their two updates and leave the data inconsistent.
await prisma.post.updateMany({ where: { featured: true }, data: { featured: false } });
await prisma.post.update({ where: { id: newPostId }, data: { featured: true } });
prisma.$transaction fixes this by running a group of writes inside a
single database transaction: either every write in the group commits, or (on error) it
rolls back as if none of them ran. Prisma offers two forms. The array
form is for a fixed batch of independent writes:
await prisma.$transaction([
prisma.post.updateMany({ where: { featured: true }, data: { featured: false } }),
prisma.post.update({ where: { id: newPostId }, data: { featured: true } }),
]);
This is already correct for the featuring example, since both writes are unconditional — neither reads a value the other produced. The interactive form is for when a later step needs the result of an earlier one, such as checking a value and then acting on it within the same atomic unit:
postsRouter.post("/:id/comments", async (req, res, next) => {
try {
const postId = Number(req.params.id);
const comment = await prisma.$transaction(async (tx) => {
const post = await tx.post.findUnique({ where: { id: postId } });
if (!post) {
throw new Error("POST_NOT_FOUND"); // throwing inside rolls back everything above
}
const created = await tx.comment.create({
data: { body: req.body.body, postId, authorId: req.user!.id },
});
await tx.post.update({
where: { id: postId },
data: { commentCount: { increment: 1 } }, // atomic increment, safe under concurrency
});
return created;
});
res.status(201).json(comment);
} catch (err) {
if (err instanceof Error && err.message === "POST_NOT_FOUND") {
return res.status(404).json({ error: "Post not found" });
}
next(err);
}
});
Every query inside the callback uses tx, the transaction-scoped client
Prisma passes in — not the top-level prisma object — so all three
statements run on the same database connection inside the same transaction. Throwing
anywhere in the callback rolls back every write that happened earlier in that same
callback, which is exactly what you want if the post lookup fails partway through: you
never want an orphaned comment with an incremented count on a post that doesn't exist.
Note also { increment: 1 } instead of reading commentCount
and writing back + 1 yourself — that read-then-write pattern has its own
race condition even inside a transaction block if done as two separate statements;
Prisma's atomic operators push the increment down to the database itself.
An interactive transaction holds a real database connection open for its entire duration. Doing a slow, non-database operation inside the callback — calling an external API, sending an email — blocks that connection under load and can exhaust your connection pool. Do that kind of work before or after the transaction, never inside it.
5. Hands-on Exercise
Extend last week's blog API with relations, and eliminate an N+1
Take the Post model from Week 4 and turn it into a real relational schema, then prove you can find and fix the query pattern that quietly ruins API performance.
Requirements:
- Add
CommentandTagmodels per Sections 1–2, withPostas one-to-many withCommentand many-to-many withTag, then runprisma migrate dev. - Write a deliberately naive
GET /postshandler that fetches posts withfindMany()and then loops to fetch each post's author and comment count separately. Enable Prisma'squerylogging and count how many queries one request produces for 20 posts. - Rewrite the same handler using
include/selectso it produces exactly one query, and confirm it in the logs. - Implement
POST /posts/:id/tagsusingconnect, and aPOST /posts/:id/featureendpoint that un-features every other post and features this one inside a single$transaction. - Implement
POST /posts/:id/commentsexactly as in Section 4 — creating the comment and incrementingcommentCountatomically — and write a test (manual or automated) that hits a nonexistent post ID and confirms no comment was created.
To confirm step 5's rollback actually happened, query the Comment table directly after the failed request and count rows for that postId — it should be zero. If you see a row, the throw happened outside the transaction callback (for example, after tx.comment.create resolved but in code that runs after $transaction returns), which defeats the whole point.
6. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does looping over a list of posts and querying each post's author individually cause N+1 queries, and why does include avoid it?
Why does looping over a list of posts and querying each post's author individually cause N+1 queries, and why does include avoid it?
The initial findMany() is one query, but looping over its N results and issuing a separate findUnique per row for the author adds N more queries — N+1 total — because each loop iteration is a fresh round trip to the database with its own connection and network overhead. include tells Prisma up front which relations you need, so it builds a single query with a JOIN that returns posts and their authors together, collapsing N+1 round trips into one.
Q2
When should you reach for an explicit join model like Enrollment instead of an implicit many-to-many relation?
When should you reach for an explicit join model like Enrollment instead of an implicit many-to-many relation?
Reach for an explicit join model the moment the relationship itself needs to carry data — an enrollment date, a grade, a role — because Prisma's implicit many-to-many join table is hidden and has no room for extra columns. If the relation is purely "these two rows are associated, nothing more," like Post and Tag, the implicit form is less code and Prisma manages its migrations for you automatically.
Q3
Why does the comment-creation example need the interactive $transaction(async (tx) => { ... }) form rather than the array form?
Why does the comment-creation example need the interactive $transaction(async (tx) => { ... }) form rather than the array form?
The array form requires every write to be constructed up front as an independent Prisma operation — it can't branch on a value one of the earlier writes produced. The comment example needs to look up the post first and decide whether to proceed at all (throwing a 404 if it's missing) before creating the comment, which is exactly the read-then-conditionally-write pattern only the interactive form, with its callback and tx client, can express.
Q4
What happens to the earlier writes inside an interactive transaction callback if a later statement in that same callback throws?
What happens to the earlier writes inside an interactive transaction callback if a later statement in that same callback throws?
Every write issued through tx inside the callback belongs to one underlying database transaction, and a thrown error anywhere in the callback causes Prisma to roll that whole transaction back — every write in it is undone, not just the one that failed. This is what prevents the comment-creation example from ever leaving an orphaned commentCount increment or a comment attached to a post that turned out not to exist.