Week 15: Advanced Prisma — Complex Queries, N+1 & Migrations at Scale

Weeks 4–5 got you to Prisma models, relations, and transactions. That's enough for straightforward CRUD; it stops being enough the moment a search endpoint needs several optional filters combined at runtime, or a list endpoint quietly fires a hundred extra queries because a relation was touched inside a loop. This week covers the query patterns that actually show up in production repositories, and the migration discipline that keeps a schema change from taking your API down mid-deploy.

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

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

  • Build dynamic, runtime-composed queries with Prisma's where clause
  • Eliminate N+1 queries by tuning include and select
  • Run batch operations and write a migration that doesn't lock the table

1. Dynamic Queries at Runtime

A repository function like getOpenTasks() works when the filters are fixed. It stops working the moment a search endpoint needs to combine an arbitrary, optional subset of filters — status, assignee, and a date range, any of which might be omitted. Prisma's where object is a plain JavaScript object, which makes composing it from optional conditions straightforward:

repositories/tasks.ts
import { Prisma } from "@prisma/client";

interface TaskSearchFilters {
  status?: string;
  assigneeId?: string;
  createdAfter?: Date;
}

async function searchTasks(filters: TaskSearchFilters) {
  const where: Prisma.TaskWhereInput = {};

  if (filters.status) where.status = filters.status;
  if (filters.assigneeId) where.assigneeId = filters.assigneeId;
  if (filters.createdAfter) where.createdAt = { gt: filters.createdAfter };

  return prisma.task.findMany({ where, orderBy: { createdAt: "desc" } });
}

Each conditional assignment only adds a key to where if the corresponding filter was actually supplied — an omitted filter simply never appears in the object Prisma receives, so it doesn't constrain the query at all. This is plain object-building, not a special query-composition API to learn, which is exactly what makes it easy to extend as new filters are added later.

Type the filters object, not just the where clause

Typing TaskSearchFilters explicitly, separate from Prisma's generated TaskWhereInput, keeps your public API surface (what a controller accepts from a query string) decoupled from Prisma's internal type — if a Prisma major version changes how WhereInput is generated, your route's accepted parameters don't have to change with it.

2. include, select & Solving N+1

Prisma doesn't lazily load relations the way an ORM like SQLAlchemy or TypeORM can — a relation is only fetched if you explicitly ask for it with include or select. That sounds like it should make N+1 impossible, but the bug reappears in a different, still-common shape: fetching a list of tasks, then calling a second Prisma query per task inside a loop or a map callback to fetch each one's assignee separately.

the N+1 pattern, Prisma-flavored
const tasks = await prisma.task.findMany();   // 1 query

const enriched = await Promise.all(
  tasks.map(async (task) => ({
    ...task,
    // fires a SEPARATE query for every single task -- N more queries
    assignee: await prisma.user.findUnique({ where: { id: task.assigneeId } }),
  }))
);

include fixes this by fetching the relation in the same round trip as the main query, via a SQL join under the hood:

the fix — a single query with include
const tasks = await prisma.task.findMany({
  include: { assignee: true },   // one query, joined
});

select goes a step further and lets you name exactly which columns to return — useful for a list endpoint that only needs three fields, avoiding both the N+1 problem and the cost of over-fetching whole rows:

select — shaping exactly what the query returns
const taskSummaries = await prisma.task.findMany({
  select: {
    id: true,
    title: true,
    assignee: { select: { id: true, name: true } },
  },
});
Enable Prisma's query logging while you work on this section

new PrismaClient({ log: ["query"] }) prints every generated SQL statement to the console — the extra N queries scroll by unmistakably the moment you iterate a list and fetch a relation per-item without include, which is a far more convincing signal than reading the code and guessing.

3. Batch Operations & Zero-Downtime Migrations

Calling prisma.task.create() in a loop for thousands of rows issues thousands of individual INSERT statements. createMany batches that into a single statement:

a batch insert
await prisma.task.createMany({
  data: csvRows.map((row) => ({ title: row.title, status: "open" })),
  skipDuplicates: true,
});

A bulk update follows the same idea — one statement affecting many rows, rather than loading each record, mutating it, and saving it back individually:

a bulk update
await prisma.task.updateMany({
  where: { updatedAt: { lt: cutoffDate } },
  data: { status: "archived" },
});

Schema changes deserve the same care. Adding a required (NOT NULL) column to a large table can lock it for the duration of the rewrite in some databases — a multi-second lock is effectively an outage for every request touching that table. The safe pattern spreads the change across separate, sequential migrations instead of one:

expand-contract across three Prisma migrations
// Migration 1 (expand): add the column optional -- no table lock
model Task {
  // ...
  priority String?   // nullable for now
}
// npx prisma migrate dev --name add_priority_nullable

// Migration 2: application code starts writing it; backfill existing rows
// UPDATE "Task" SET priority = 'normal' WHERE priority IS NULL;
// (run as a one-off script or a `prisma migrate dev --create-only` custom migration)

// Migration 3 (contract): once backfilled and the app fully depends on it
model Task {
  // ...
  priority String   // now required
}
// npx prisma migrate dev --name make_priority_required

No single migration in that sequence requires the old and new application code to disagree about the schema — which matters directly for a rolling deploy, where old and new instances briefly run side by side. A one-step migration that adds a required column the app immediately depends on breaks the moment an old instance, still serving traffic mid-rollout, tries to insert a row without it.

Every migration should work with both the old and new app code running at once

This single rule is what generates the whole expand-contract pattern above: because a rolling deploy always has a window with two versions live, any migration that would break if only one version's assumptions held is unsafe by definition. Apply this instinct to every schema change, not just ones on large tables — it costs little on a small table and becomes a genuine habit for when it matters.

4. Hands-on Exercise

Hands-on

Build a dynamic search endpoint, kill a real N+1, and run a safe migration

Apply all three practices to the task service from earlier weeks.

Requirements:

  1. Build a GET /tasks/search endpoint accepting optional status, assignee, and date filters, composed at runtime — confirm it works correctly with zero, one, and all filters supplied.
  2. Reproduce a real N+1: seed at least 30 tasks with assignees, fetch the list, then fetch each assignee in a separate per-item query, and count the queries with Prisma's query logging enabled. Then add include and confirm the query count drops to one.
  3. Write a createMany seeding at least 1,000 rows, and compare its timing against a loop of individual create() calls doing the same thing.
  4. Write a three-migration expand-contract sequence adding a required column to an existing table, with your application still running (and working) throughout all three.
Hint

Prisma's $on("query", ...) event listener (available when you configure log: [{ emit: "event", level: "query" }]) gives you a precise, assertable count of queries fired during a test — a more reliable signal than eyeballing console log lines.

5. Knowledge Check

Three quick questions. Expand each to check your answer.

Q1

Why doesn't Prisma's explicit-relation-loading model automatically prevent N+1 queries?

Prisma requires you to explicitly ask for a relation with include or select rather than lazily loading it on access, which prevents the classic "touching a lazy property" version of N+1. But nothing stops a developer from manually calling a second Prisma query inside a loop or a map callback over a list — that's still N separate queries, just written explicitly instead of triggered implicitly.

Q2

What's the practical difference between using include and using select to fetch a relation?

include fetches the relation alongside every column of the parent model already selected by default. select lets you name exactly which columns — on both the parent and any nested relation — the query should return, which avoids over-fetching whole rows you don't need on top of solving the same N+1 problem include solves.

Q3

Why does a rolling deploy require an expand-contract migration instead of adding a required column in one step?

A rolling deploy always has a window where old and new application instances run simultaneously against the same database. A single-step migration that immediately requires the new column breaks the old instances still serving traffic during that window, since they have no code path that populates it — splitting the change into expand, backfill, and contract phases keeps both versions working throughout the entire rollout.