Week 5: Relations, the N+1 Problem & Transactions

Week 4 got you a working schema.prisma, migrations, and single-table CRUD wired into Express routes — but almost no real API is single-table. This week you'll model one-to-many and many-to-many relations properly, diagnose the single most common Prisma performance bug (the N+1 query problem) and fix it with include/select, and wrap multi-step writes in prisma.$transaction so concurrent requests can't leave your data half-updated. The transactional thinking you build here comes back directly in Week 8, when you'll reach for the same primitive to roll back test data between test cases instead of rebuilding a database from scratch every run.

Module 4 of 22 Week 5 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Model one-to-many and many-to-many relations in a Prisma schema, including when to reach for an explicit join model
  • Recognize the N+1 query pattern in application code and eliminate it with include or select
  • Wrap multi-step writes in prisma.$transaction so they succeed or fail as a single atomic unit

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:

prisma/schema.prisma
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.

src/routes/posts.ts
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);
  }
});
Without 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:

prisma/schema.prisma
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
src/routes/posts.ts
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:

prisma/schema.prisma — explicit join, when you need extra fields
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
}
Default to implicit, upgrade when you need to

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:

src/routes/posts.ts — the bug
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:

src/routes/posts.ts — fixed
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.

Turn on query logging while you're learning this

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:

the race condition — do not do this
// 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:

array form — independent writes, no dependency between them
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:

src/routes/posts.ts — interactive transaction
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.

Keep transaction callbacks short

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

Hands-on

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:

  1. Add Comment and Tag models per Sections 1–2, with Post as one-to-many with Comment and many-to-many with Tag, then run prisma migrate dev.
  2. Write a deliberately naive GET /posts handler that fetches posts with findMany() and then loops to fetch each post's author and comment count separately. Enable Prisma's query logging and count how many queries one request produces for 20 posts.
  3. Rewrite the same handler using include/select so it produces exactly one query, and confirm it in the logs.
  4. Implement POST /posts/:id/tags using connect, and a POST /posts/:id/feature endpoint that un-features every other post and features this one inside a single $transaction.
  5. Implement POST /posts/:id/comments exactly as in Section 4 — creating the comment and incrementing commentCount atomically — and write a test (manual or automated) that hits a nonexistent post ID and confirms no comment was created.
Hint

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?

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?

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?

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?

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.