Prisma入門:include/selectとN+1回避でクエリを型安全にする書き方
Prisma ORMの型安全クエリを実例で。include/selectでリレーションを取り、N+1を防ぎ、transactionで整合性を守る。Claude Codeに任せる勘所も。
「記事一覧、ちゃんと出てるな」
ローカルでそう確認して安心していたら、本番で一覧ページが妙に遅い。ログを見たら、20件の記事を出すために、SQLが41回飛んでいました。記事を取るクエリが1回、その記事それぞれの著者名を取るクエリが20回、カテゴリを取るのが20回。合わせて41回。
これがN+1問題です。僕も最初にやらかしました。1件あたり数ミリ秒だから、ローカルの数件では気づけない。データが増えてから、じわっと効いてくる。
Prisma ORM(TypeScriptからDBを型付きで触る道具)には、これを防ぐ書き方がちゃんと用意されています。includeとselectの使い分けです。今日はそこを中心に、コピペで動くクエリを並べながら解説します。
この記事の要点
- Prismaは
schema.prismaに「DBの形」を宣言すると、型付きのクエリ関数(Prisma Client)が自動で生える。フィールド名を変えればエディタが赤線で教えてくれる。 - リレーション(記事→著者みたいな繋がり)を一緒に取るときは
includeかselect。includeは「全部のせ+関連」、selectは「指定したものだけ」。公開APIはselectが基本。 - N+1(取得が芋づる式に増える事故)は、関連を
include/selectでまとめて取れば1〜2回のクエリに畳める。ループの中でクエリを呼ばないのが鉄則。 - 「更新+通知+ログ」みたいに、まとめて成功か失敗かにしたい処理は
$transactionで囲む。ただし中でメール送信などの遅い処理はしない。 - スキーマ設計そのものはテーブル設計の判断軸、移行運用はDBマイグレーション運用へ。この記事は「Prismaでのクエリの書き味」に絞る。
Prismaが「型安全」って、どういうこと
普通、DBの中身はただの文字列やテーブルです。アプリ側のコードからすると、user.namae(タイプミス)と書いても、実行するまで誰も気づかない。本番で初めて「そんなカラムない」と落ちる。
Prismaは、schema.prismaという1枚のファイルに「DBにはこういうテーブルとカラムがある」と宣言させます。すると、そこからTypeScriptの型と、その型に沿ったクエリ関数が自動生成されます。
つまり、user.namaeと打った瞬間にエディタが赤線を引く。post.authorと書けば、そのauthorがUser型だと分かっているから、.nameまで補完が効く。DBの構造とコードがズレたら、実行前に分かる。これが「型安全」の中身です。
身近に例えると、宛名ラベルの印刷に似ています。手書きだと住所を間違えても投函するまで気づかない。テンプレートに差し込み印刷するなら、項目名が違えば印刷前にエラーが出る。Prismaは後者です。
公式の入口はPrisma ORM docs。クエリの書き方を確かめたいときは、まずここを見ると、Claude Codeの出力が古い書き方になっていないか照合できます。
include と select:関連データの取り方
ここがPrismaを使ううえで、いちばん最初に迷うところです。記事を取るとき、著者名やカテゴリも一緒に欲しい。そのとき使うのがincludeとselectの2つ。役割が違います。
| 書き方 | 取れるもの | 向いている場面 |
|---|---|---|
include | そのモデルの全カラム + 指定した関連 | 開発中、社内ツール、全部欲しいとき |
select | 指定したカラムだけ(関連も指定可) | 公開API、一覧、返したくない列があるとき |
言葉より、コードのほうが早いです。
// include:Postの全カラム + 著者を丸ごと取る
const post = await prisma.post.findUnique({
where: { slug: "hello" },
include: { author: true },
});
// post.body も post.author.email も全部入っている
// select:欲しいカラムだけ。author も name だけ
const card = await prisma.post.findUnique({
where: { slug: "hello" },
select: {
id: true,
title: true,
author: { select: { name: true } },
},
});
// card.body は存在しない(型レベルで弾かれる)。author も name のみ
ここが効くのは、返してはいけないデータを返さないという点です。Userにパスワードハッシュやメールアドレスが入っているとき、include: { author: true }だと、それがAPIレスポンスに混ざります。selectでnameだけ指定しておけば、そもそも取得しない。速度にもセキュリティにも効きます。
僕のルールはシンプルで、「人に見せるエンドポイントはselect、自分しか見ない管理スクリプトはincludeで雑に」。迷ったらselectにしておけば、後で「なんでメアドが漏れてるの」と青ざめずに済みます。
N+1問題を、includeで畳む
冒頭の41回クエリ。あれは、こういうコードで起きます。やりがちなので、まず「ダメな例」を見てください。
// アンチパターン:N+1が起きる書き方
const posts = await prisma.post.findMany({ take: 20 }); // ① 1回
for (const post of posts) {
// ② ループの中で毎回クエリ → 20回
const author = await prisma.user.findUnique({
where: { id: post.authorId },
});
console.log(post.title, author?.name);
}
// 合計 1 + 20 = 21回。カテゴリも足すと41回
問題は、ループの中でクエリを呼んでいること。記事が増えるほどクエリも増えます。ローカルの3件では「速いね」で済み、本番の数千件で詰まる。気づきにくいのは、コードが素直に読めてしまうからです。
直し方は、最初の1回で関連も一緒に取ること。Prismaは関連をinclude/selectすると、内部で効率の良いクエリにまとめてくれます。
// 正しい書き方:関連をまとめて取る
const posts = await prisma.post.findMany({
take: 20,
select: {
title: true,
author: { select: { name: true } }, // 著者をまとめて取得
categories: { select: { category: { select: { name: true } } } },
},
});
for (const post of posts) {
// ここではクエリを呼ばない。もう全部メモリにある
console.log(post.title, post.author.name);
}
// クエリは数回で済む。ループ内はゼロ
ポイントは「ループに入る前に、必要なデータを取り切る」。これだけでN+1の9割は消えます。Claude Codeにクエリを書かせたときも、僕は真っ先に「forやmapの中でprisma.を呼んでいないか」を見ます。呼んでいたら、ほぼN+1です。
リレーションそのものの考え方(1対多か多対多か、中間テーブルが要るか)はテーブル設計の判断軸で扱っています。この記事は「設計済みの関連をどう取るか」に集中します。
コピペで動く最小セット
ここまでの話を、手元で動かせる形にまとめます。SQLiteなので、追加のDBサーバーは不要です。Node.jsだけで動きます。
まず準備。
mkdir prisma-demo && cd prisma-demo
npm init -y
npm install @prisma/client
npm install -D prisma tsx typescript
echo 'DATABASE_URL="file:./dev.db"' > .env
mkdir prisma
次にスキーマ。記事・著者・カテゴリの最小構成です。@@indexは「よく絞り込む列に検索の目印を付ける」もの。一覧の表示が速くなります。
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
posts Post[]
}
model Post {
id String @id @default(cuid())
slug String @unique
title String
body String
status String @default("DRAFT")
publishedAt DateTime?
authorId String
author User @relation(fields: [authorId], references: [id])
categories PostCategory[]
@@index([status, publishedAt]) // 公開一覧の WHERE/ORDER BY に効く
}
model Category {
id String @id @default(cuid())
slug String @unique
name String
posts PostCategory[]
}
model PostCategory {
postId String
categoryId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
@@id([postId, categoryId])
}
スキーマからDBとClientを作ります。
npx prisma migrate dev --name init
あとはクエリを書いて実行するだけ。著者とカテゴリを一緒に取り、件数も同時に数えます。
// demo.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
// 著者を作りつつ、記事を1本作る
const author = await prisma.user.upsert({
where: { email: "[email protected]" },
update: {},
create: { email: "[email protected]", name: "Masa" },
});
await prisma.post.create({
data: {
slug: `hello-${Date.now()}`,
title: "はじめてのPrisma",
body: "本文です",
status: "PUBLISHED",
publishedAt: new Date(),
authorId: author.id,
},
});
// 公開記事を、著者名つきで一覧。N+1にならない取り方
const posts = await prisma.post.findMany({
where: { status: "PUBLISHED" },
orderBy: { publishedAt: "desc" },
select: {
title: true,
publishedAt: true,
author: { select: { name: true } },
},
});
console.log(posts);
}
main().finally(() => prisma.$disconnect());
npx tsx demo.ts
posts[0].author.nameまで型が効いているのが、エディタで触ると分かります。author.emailを足そうとすると、selectに入れていないので赤線が出る。これがPrismaの普段の書き味です。
$transaction:まとめて成功か、まとめて失敗か
記事を「公開」するとき、やりたいことが3つあるとします。記事のステータスを更新する、著者に通知を作る、監査ログを残す。ここで怖いのは、途中で失敗することです。ステータスだけ変わって通知が作られない、みたいな中途半端な状態が残ると、後でデータの辻褄が合わなくなる。
そこで$transactionで囲みます。中の処理は「全部成功」か「全部なかったことに(ロールバック)」のどちらかになります。
// 公開処理:3つをまとめて、成功か失敗かに揃える
export async function publishPost(postId: string) {
return prisma.$transaction(async (tx) => {
const post = await tx.post.findUnique({
where: { id: postId },
select: { id: true, title: true, status: true, authorId: true },
});
if (!post) throw new Error("記事が見つかりません");
if (post.status === "PUBLISHED") return post; // すでに公開済みなら何もしない
const published = await tx.post.update({
where: { id: post.id },
data: { status: "PUBLISHED", publishedAt: new Date() },
select: { id: true, title: true, status: true, publishedAt: true },
});
await tx.notification.create({
data: {
userId: post.authorId,
type: "POST_PUBLISHED",
message: `${post.title} を公開しました`,
},
});
return published;
});
}
tx(トランザクション用のClient)を使うのがコツです。中でprismaではなくtxを呼ぶことで、3つの操作が同じトランザクションに乗ります。
一方で、独立した読み取りを2つまとめたいだけなら、配列を渡す軽い書き方もあります。一覧と総件数を同時に取るときの定番です。
const [items, total] = await prisma.$transaction([
prisma.post.findMany({ where: { status: "PUBLISHED" }, take: 20 }),
prisma.post.count({ where: { status: "PUBLISHED" } }),
]);
ひとつだけ、強く言いたい落とし穴があります。トランザクションの中でメール送信や外部API呼び出しをしないこと。トランザクションはDBの接続を握り続けます。中で遅いメール送信を待つと、その間ずっと接続が塞がり、アクセスが増えたときにデッドロックや性能低下を招きます。通知を「DBに記録する」まではトランザクション内でいい。でも「実際に送る」のは外に出す。送信待ちのレコードを別テーブル(Outbox)に積んで、別のワーカーに処理させるのが安全です。公式のTransactions and batch queriesにも同じ注意が書かれています。
こんな場面で効く(3つ)
1. コンテンツサイトの記事一覧
公開記事を新しい順に20件、著者名とカテゴリ付きで出す。ここでselectを使って著者のnameだけ取れば、メアドや下書き本文を漏らさず、N+1も起きません。@@index([status, publishedAt])があるので絞り込みと並べ替えも速い。
2. SaaSのワークスペース機能
複数チームが同じDBを共有するとき、すべてのクエリにwhere: { tenantId }が入っていないと、他社のデータが見えてしまう。Claude Codeにクエリを書かせたら、「全クエリにtenantId条件が入っているか」を必ずレビューします。型では防げない、人間が見る領域です。
3. 決済後の権限付与
「支払い完了→プラン更新→請求ログ記録」を$transactionでまとめる。さらに、同じWebhookが2回来ても二重に付与しないよう、処理済みIDを記録して弾く。お金が絡むところは、整合性を型ではなくトランザクションで守ります。
僕がやらかした失敗3つ
正直に書きます。Prismaを使い始めた頃のクエリは、事故だらけでした。
ひとつ目は、さっきのN+1です。一覧ページでmapの中にprisma.user.findUniqueを書いていた。ローカルでは爆速、本番で激重。以来、「ループの中でprisma.を見たら赤信号」と体に刻みました。
ふたつ目は、include: trueの乱用です。とりあえず全部のせれば動くので、関連を片っ端からincludeしていた。結果、APIレスポンスにUserのメアドや内部フラグが混ざっていて、後から「これ外に出してるよ」と指摘されて冷や汗。公開する出口はselectに統一しました。
みっつ目は、トランザクションの中でメールを送ったこと。「ついでだから」と公開トランザクションの中に送信処理を入れたら、SMTPが詰まった日にDB接続が枯れて、サイト全体が固まりました。送信はトランザクションの外。これは痛い目で覚えました。
Claude Codeにクエリを任せるときの勘所
Prismaのクエリは定型が多いので、Claude Codeに書かせると速いです。ただ、出力をそのまま信じると、上の失敗をAIも普通にやります。僕が必ず見る観点を、そのままプロンプトにしておくと楽です。
このPrismaクエリをレビューしてください。
- for / map の中で prisma. を呼んでいないか(N+1の兆候)
- 公開エンドポイントで include: true を使っていないか。select で必要な列だけにできるか
- findMany に take の上限があるか(ユーザー入力をそのまま渡していないか)
- $transaction の中で外部API・メール送信・遅い処理をしていないか
- マルチテナントなら全クエリに tenantId 条件が入っているか
- $queryRawUnsafe を使っているなら、なぜ必要か。説明できないなら使わない
生SQLを使う場面についても一言。集計など、Prisma Clientで書きにくい処理は生SQLに頼ることがありますが、ユーザー入力を文字列で連結すると一発でSQLインジェクションの穴が空きます。$queryRawのタグ付きテンプレートかPrisma.sqlを使えば、値が安全に差し込まれます。
import { Prisma } from "@prisma/client";
// 安全:値はテンプレートのプレースホルダ経由で渡る
const rows = await prisma.$queryRaw<{ authorId: string; postCount: bigint }[]>(
Prisma.sql`
SELECT "authorId", COUNT(*) AS "postCount"
FROM "Post"
WHERE "status" = ${"PUBLISHED"}
GROUP BY "authorId"
ORDER BY "postCount" DESC
LIMIT ${10}
`
);
マイグレーションをどう運用するか(db pushで済ませない、生成されたSQLをPRで読む、ロールバックの手順)は、それ自体で1記事ぶんの重さがあります。そちらはDBマイグレーション運用の事故を防ぐにまとめたので、本番に出す前に目を通してください。Claude Codeの基本操作から入りたい人はClaude Code入門、Prismaより軽いORMと迷っている人はDrizzle ORM実装も参考になります。
よくある質問
Q. includeとselectは、結局どっちを使えばいい?
A. 公開APIや一覧はselect、自分しか見ない管理スクリプトはincludeで雑に。迷ったらselect。返したくない列を最初から取らない方が安全です。両方を同じクエリで混ぜることはできません(トップレベルはどちらか一方)。
Q. selectでもN+1は起きる?
A. 関連をselectの中で指定していれば、まとめて取られるのでN+1にはなりません。N+1が起きるのは、ループの中で別途findUniqueなどを呼ぶときです。「関連はクエリの中で取る、ループの外で取り切る」を守れば防げます。
Q. $transactionの配列版とコールバック版、どう使い分ける?
A. 配列版($transaction([...]))は独立した複数の読み書きをまとめるだけのとき。コールバック版($transaction(async (tx) => {...}))は、前の結果を見て次を分岐するなど、途中のロジックが要るとき。公開処理のように条件分岐があるならコールバック版です。
Q. SQLiteで作ったスキーマは、PostgreSQLでもそのまま動く?
A. クエリの書き方とスキーマの考え方はほぼ同じです。datasourceのproviderを変え、接続URLを差し替えるのが基本。ただしenumや一部の型、@db.属性はDBごとに差があるので、本番DBで一度マイグレーションを通して確認します。
Q. Prisma Clientの型が古いままで補完が効かない
A. スキーマを変えたらnpx prisma generateを実行して型を作り直します。migrate devを使うと自動で生成されますが、schema.prismaだけ編集したときは手動でgenerateが必要です。
実際に試した結果
冒頭の「41回クエリ」事件のあと、僕がやったのはコードを書き直すことだけでした。mapの中のfindUniqueを消して、最初のfindManyにselectで著者とカテゴリを入れる。それだけで、クエリは41回から3回に減りました。一覧ページの体感は、明らかに変わりました。
今はクエリを書くたび、頭の中でこの順に確認します。ループの中でクエリを呼んでいないか、公開する出口はselectになっているか、takeに上限があるか、トランザクションの中に遅い処理がないか。Claude Codeに書かせたコードも、同じ4点で見ます。Prismaの型は「カラム名のタイプミス」は防いでくれますが、「N+1」と「余計な列の漏れ」と「重いトランザクション」は防いでくれません。そこは人間が見る。型に守ってもらう部分と、自分で見張る部分を分けてから、Prismaがぐっと使いやすくなりました。
もっと体系的に型・クエリ・レビュー観点を整理したい人は教材一覧も覗いてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeのコマンドを覚えたのに手が止まる人へ、最初の一手を安全に出す型
コマンド表を覚えたのに何を頼めばいいか分からない。読むだけで終わらず、初めての一手を安全に通すまでの手順とプロンプト雛形を紹介します。
Claude Codeで既存リポジトリの最初の1編集を安全にする導入手順
他人が書いた既存コードにClaude Codeを入れる初日に、触らせる範囲・禁止領域・検証コマンドを先に決めて事故を防ぐ実践手順。
Claude Codeに最初の1タスクを任せる依頼文の書き方
Claude Codeへの最初の依頼で事故らないために、目的・触ってよい範囲・検証・戻し方を1枚にまとめる依頼文の型を、コピペ例つきで紹介します。