ほとんどのアプリケーションにおける正直なデフォルトは、日常的なCRUDの90%にはORMまたはクエリビルダーを使用し、明確に効果が見込める一部のレポーティング・分析・ホットパスクエリには手書きSQLを使用するというものです。これはどちらか一方を選ぶ問題ではありません。本当に重要な問いは「抽象的にどちらが優れているか」ではなく、「このクエリに対して、このチームで、これらの制約のもとでどちらを使うべきか」です。本記事では判断基準、比較表、そしてRAWな抽象化オーバーヘッドよりもはるかに多くのチームを悩ませる唯一の失敗パターン——N+1クエリ——を解説します。例はJS/TSスタック(Prisma、Drizzle、Kysely、Knex)を中心に展開します。現在、こうした意思決定の多くがこのスタック上で行われているためです。
重要なポイント
- 日常的なCRUDにはORMまたはクエリビルダーをデフォルトとして使用し、明確に必要性が認められるレポーティング・分析・ホットパスクエリに対してのみRaw SQLを使用する——ほとんどの本番アプリは両方を併用している。
- ORMの実際のパフォーマンスコストは、ほぼ例外なく抽象化そのものではなく、N+1クエリパターンにある。リストを取得してからそのリレーションを1行ずつ取得すると、1回のページロードが数百回のラウンドトリップに膨れ上がる。
- N+1の解決策はORMを捨てることではなく、単一クエリでリレーションをEager-load(Prismaの
includeまたはJOIN)し、レンダリングに必要なカラムだけを選択することにある。 - Drizzle、Kysely、KnexといったクエリビルダーはSQLに近い形で型チェックされたクエリを書ける第三の選択肢であり、生成SQLを完全にコントロールでき、オブジェクトマッピング層を持たない。
- ORMはデフォルトでクエリをパラメータ化するため、最も一般的なSQLインジェクションの経路を遮断する。Raw SQLは、パラメータ化されたステートメントをどれだけ規律を持って使用できるかにかかっている。
ORMとは何か、「Raw SQL」とは何を意味するか
**ORM(オブジェクト・リレーショナル・マッパー)**とは、データベースのテーブルをプログラミング言語のオブジェクトにマッピングするライブラリです。SQLを手書きする代わりに、メソッド呼び出しと型付きモデルを通じてデータをクエリできます。ORMはSQLを生成してデータベースに送信し、結果の行をオブジェクトに変換(ハイドレーション)する処理を担い、リレーション、パラメータ化、型マッピングも自動的に処理します。JS/TSの世界では、PrismaとTypeORMがフルORMの代表例です。典型的なPrismaの読み取り処理は次のようになります。
// ユーザーとその投稿を1回の呼び出しで取得
const users = await prisma.user.findMany({
where: { posts: { some: { title: { contains: 'test' } } } },
include: { posts: true },
});
// users[0].posts はすでに型付き配列 — 手動での変換は不要
Raw SQLとは、クエリのテキストを自分で記述し、データベースドライバを通じて実行し、フラットな結果行をアプリケーションが必要とする構造に手動で変換することを意味します。クエリを完全にコントロールできる一方、マッピング処理も自分で担う必要があります。pgを使って「ユーザーとその投稿」を同様に取得するには、JOINと手動の再グループ化が必要です。
const { rows } = await pool.query(
`SELECT u.id, u.name, p.id AS post_id, p.title
FROM users u
LEFT JOIN posts p ON p.author_id = u.id
WHERE p.title LIKE $1`,
['%test%'],
);
// フラットな行 -> ネストされたユーザー構造への変換を手動で実施
const users = Object.values(
rows.reduce((acc, r) => {
acc[r.id] ??= { id: r.id, name: r.name, posts: [] };
if (r.post_id) acc[r.id].posts.push({ id: r.post_id, title: r.title });
return acc;
}, {} as Record<number, { id: number; name: string; posts: any[] }>),
);
このreduce処理こそが、リレーショナルデータの読み取りにおけるRaw SQLの実際のコストです。データベースはオブジェクトグラフではなく行を返すため、JOINと再グループ化のロジックを毎回自分で書く必要があります。また、$1プレースホルダーも重要な役割を果たしています——安全性については後述します。
ORM vs Raw SQL: 実際の意思決定を左右する比較
Discover how at OpenReplay.com.
判断は5つの基準に集約されます。フルORM、クエリビルダー、Raw SQLの3つのアプローチをそれぞれの観点で比較すると次のようになります。
| 基準 | フルORM(Prisma、TypeORM) | クエリビルダー(Drizzle、Kysely、Knex) | Raw SQL |
|---|---|---|---|
| インジェクション安全性 | デフォルトでパラメータ化 | デフォルトでパラメータ化 | パラメータ化されたステートメントを使用した場合のみ安全 |
| 開発速度(CRUD) | 最速——リレーション・ハイドレーションが自動処理 | 高速——SQLに近い形式でJOINを自分で記述 | 最低速——手動マッピングが必要 |
| マイグレーション / スキーマ | 第一級サポート(Prisma Migrate、TypeORM) | 組み込みまたは連携ツール(Drizzle Kit、Knexマイグレーション) | 手書きのDDLスクリプト |
| DB間の移植性 | 高——プロバイダーの切り替えが容易 | 中——方言を意識した設計 | 低——方言固有のSQL |
| コントロール / DB固有機能 | 限定的;エスケープハッチが必要 | 高——SQLに近い | 完全——あらゆる機能・ヒントが利用可能 |
| 型安全性 | スキーマからエンドツーエンドで保証 | 強力(Kysely/Drizzle);ベストエフォート(Knex) | 追加ツールなしでは保証なし |
安全性については、実際のところシンプルです。ORMとクエリビルダーはデフォルトで値をパラメータ化するため、古典的なインジェクションの経路を遮断します。Raw SQLの安全性はあなた自身の規律にかかっています——Prismaの公式ドキュメントでも、unsafeメソッドをユーザー入力と組み合わせて使用するとSQLインジェクション攻撃の可能性が生まれ、データの改ざんや削除につながりかねないと警告されています。プレースホルダー($1、?)を使用し、ユーザー入力をクエリ文字列に直接埋め込まない限り、Raw SQLは安全です。その規律を怠れば安全ではありません。
マイグレーションと移植性については、ORMが日常的なユースケースで優位に立ちます。スキーマ変更はバージョン管理された再現可能なマイグレーションとなり、環境間で同一に実行されます。また、データベースプロバイダーの切り替えはほぼ設定変更だけで済みます。Raw SQLはどちらも自動では提供されません——DDLスクリプトを自分で管理し、クエリは特定の方言の構文に依存します。コントロールの面ではRaw SQLが圧倒的に優れています。ウィンドウ関数、再帰CTE、データベース固有のインデックスヒント、EXPLAIN ANALYZEによるチューニングなど、抽象化と戦うことなくすべての機能を利用できます。
パフォーマンスの真実:問題は抽象化ではなくN+1にある
ORMに対する最もよく聞かれる批判——「遅いクエリを生成する」——は、CRUDに関してはほぼ間違いであり、特定のパターンに関してはほぼ正しいと言えます。現代のORMは日常的な読み書きに対して十分なSQLを生成します。実際のパフォーマンスコストはN+1クエリ問題にあります。N行のリストを取得した後、各行に対してリレーションを取得するクエリを1つずつ発行することで、1回のページロードがデータベースへのN+1回のラウンドトリップに変わってしまうパターンです。
以下は典型的な失敗例で、一見無害なループの中に潜んでいます。
// ユーザーを取得する1クエリ...
const users = await prisma.user.findMany();
// ...その後、ユーザーごとにN回のクエリ — これがN+1の罠
for (const user of users) {
user.posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
解決策はORMを捨てることではありません。単一のクエリでリレーションをEager-loadし、実際にレンダリングするカラムだけを選択します。
const users = await prisma.user.findMany({
include: { posts: { select: { id: true, title: true } } },
});
これにより、1+Nクエリが1クエリに集約されます。N+1が修正されていない場合のユーザー体験上の症状は、シードデータでは快適に動作するのに本番ボリュームになると遅くなるリストやダッシュボードページです——まさにセッションリプレイがユーザー側から表面化させるフロントエンドのレイテンシであり、クエリ層に立ち戻ってクエリ数を数え、Eager-loadingを追加するよう指し示してくれます。
ツールを責める前に生成されたSQLを確認する
ORMが遅いと結論づける前に、実際に何を出力しているかを確認しましょう。Prismaはlog: ['query']オプションを指定してクライアントをインスタンス化すると全クエリをログに記録します。Kyselyは.compile()メソッドでSQLとパラメータを返し、DrizzleはクエリにToSQLメソッドを公開しています。SQLを取得したら、EXPLAIN ANALYZEを実行して、データベースが実際に実行するプランを確認します。
const prisma = new PrismaClient({ log: ['query'] });
// Prismaが実行するすべてのクエリが出力される — クエリ数を数え、EXPLAIN ANALYZEにコピーして検証
これにより「ORMが遅い」という感覚的な判断が、検証可能なワークフローに変わります。SQLを読み、ラウンドトリップ数を数え、実行プランを確認する、というプロセスです。
エンジン自体についても触れておきます。Prisma 7における最も重要な変更の一つは、RustベースのクエリエンジンをTypeScript実装に完全に置き換えたことです。新しいQuery CompilerはTypeScriptとWebAssemblyで動作し、言語間のシリアライゼーションステップが不要になることでクエリ実行が高速化されています。また、エンジンがネイティブバイナリに依存しなくなったため、Cloudflare Workers、Bun、DenoなどJavaScriptまたはWASMをサポートする環境でPrismaを使用できるようになりました。Prismaによれば、この書き直しにより最も重要な大規模・複雑なクエリで大幅な高速化が実現されており、シンプルなクエリでは同等のパフォーマンスを維持しています。この比較における要点は、エンジンをめぐる議論は形を変えただけで消えてはいないが、依然としてクエリ数の問題が優先されるということです。遅いページの圧倒的多数の原因は、N+1と過剰フェッチであり、抽象化レイヤーではありません。
クエリビルダー:二項対立の枠組みが見落とす第三の選択肢
「ORM vs Raw SQL」という枠組みは、強力な中間地点を見えにくくしています。Drizzle、Kysely、Knexといったクエリビルダーを使えば、SQLに近い形式で型チェックされたクエリを記述しつつ、生成されるSQLを完全にコントロールでき、ORMのオブジェクトマッピング層を持ちません。KyselyはTypeScript向けの最も強力な型安全SQLクエリビルダーであり、Deno、Maersk、Cal.comなどの本番環境で使用されています。モダンなTypeScriptとゼロランタイムオーバーヘッドを特徴とし、命名と構造を通じた親しみやすさと、1対1のコンパイルによる予測可能性を重視した、SQLの薄い抽象化レイヤーです。
const users = await db
.selectFrom('users')
.innerJoin('posts', 'posts.author_id', 'users.id')
.select(['users.id', 'users.name', 'posts.title'])
.where('posts.title', 'like', '%test%')
.execute();
形式はSQLに近いですが、カラム名とテーブル名はスキーマに対して型チェックされます。Kyselyのドキュメントが述べるように、クエリビルダーはSQLを完全にコントロールしながら、TypeScriptがコードの実行前に早期にミスを検出できる環境を提供します。KnexはPostgres、MySQL、SQLiteなど複数のデータベースに対応した長年の定番ですが、型付けの限界に注意が必要です。Knex自身のガイドによれば、TypeScriptサポートはベストエフォートであり、すべての使用パターンを型チェックできるわけではなく、型エラーがないからといって生成されるクエリが正しいことは保証されないため、TypeScriptを使用していてもテストの作成が推奨されています。型安全性をORMの重厚さなしに求める新しいTSプロジェクトには、KyselyまたはDrizzleの方が適した選択です。
シナリオ別の判断基準
コードベース全体で一つのツールを選ぶのではなく、ワークロードに合わせてツールを選択しましょう。
- シンプルから中程度のCRUD、チームでの開発、進化するスキーマ → ORM。 パラメータ化されたクエリ、バージョン管理されたマイグレーション、リレーションの読み込み、エンドツーエンドの型安全性が得られます。これはほとんどのアプリケーションコードをカバーします。
- レポーティング、分析、バルク操作、または計測されたホットパス → Raw SQL、ビュー、またはストアドプロシージャ。 ウィンドウ関数、複雑な集計、
EXPLAIN ANALYZEで検証した手動チューニングが必要な場合は、SQLを直接記述します。 - ORMなしで型安全性とSQLコントロールを両立したい → クエリビルダー。 DrizzleまたはKyselyは、予測可能な出力と隠れたN+1動作のない、SQLに近い型付きクエリを提供します。
ほとんどの本番アプリが落ち着くハイブリッド構成
実際のところ、成熟したコードベースはどちらか一方を選ぶのではなく、大部分の処理にORMを使用しつつ、残りのためにRaw SQLのエスケープハッチを維持しています。Prismaはこれを**TypedSQL**という機能で明示的にサポートしています。これはPrisma ORMの機能であり、完全に型安全な方法でRaw SQLクエリを記述できるものです。.sqlファイルにSQLを記述し、生成された型付き関数を通じて呼び出します。
import { getUsersByAge } from './generated/prisma/sql';
const users = await prisma.$queryRawTyped(getUsersByAge(18, 30));
これはプレビュー機能です。ジェネレーターブロックでpreviewFeatures = ["typedSql"]を指定して有効化します。静的に型付けされているため、動的に生成されるWHERE句などのシナリオには対応していない場合があり、そのような場合は引き続き型なしのRawメソッドが必要です。Prismaは本記事と同じワークフローを提示しています。大多数のクエリに対応する高レベルの抽象化と、SQLを直接記述する必要がある場合のための型安全なエスケープハッチです。これが「ORM vs Raw SQL」という問いに対する現代的な答えが「必要に応じてRaw SQLにエスケープできるORM(またはクエリビルダー)」となる理由です。
まとめ
日常的なCRUDにはORMまたはクエリビルダーをデフォルトとして使用し、明確に必要性が認められるクエリにはRaw SQLを使用しましょう。両者を対立する陣営ではなく、一つのデータアクセス戦略における層として捉えることが重要です。既存プロジェクトで今すぐ取れる具体的な次のステップは、クエリログを有効化し、リストやダッシュボードのエンドポイントを特定し、リクエストごとのクエリ数を数え、Eager-loadingとカラム選択の絞り込みによってN+1を修正することです——この一手間だけで、ORMとSQLの書き換えよりもはるかに大きなパフォーマンス改善が得られることがほとんどです。
よくある質問
ORMとクエリビルダーの違いは何ですか?
ORMはデータベースのテーブルをオブジェクトにマッピングし、クエリ結果を型付きモデルにハイドレーションします。リレーションとオブジェクトマッピングを自動的に処理します。一方、クエリビルダーは型チェックされたSQLを生成しますが、オブジェクトマッピング層を持たず、プレーンな行を返します。PrismaとTypeORMがORM、Drizzle、Kysely、KnexがクエリビルダーにあたりSQLに近い形式で動作し、生成クエリをより細かくコントロールでき、抽象化が少なく、隠れたリレーション読み込み動作もありません。
ORMを使用するとSQLインジェクションを防げますか?
通常の使用においてはYesです。ORMとクエリビルダーはデフォルトで値をパラメータ化し、ユーザー入力をクエリ文字列に直接埋め込む代わりにバインドパラメータとして送信するため、古典的なインジェクションの経路を遮断します。例外はunsafeなRawクエリメソッドです。Prismaのドキュメントでは、unsafeメソッドをユーザー入力と組み合わせて使用するとSQLインジェクションの可能性が生まれると警告されています。Raw SQLは`$1`や`?`などのプレースホルダーを使用する規律を守る限りにおいてのみ安全です。
Raw SQLはORMより速いですか?
ほとんどの実際のケースではNoです。現代のORMは日常的なCRUDに対して十分なSQLを生成しており、通常のパフォーマンス差は抽象化ではなくクエリ数から生じます。遅いページの主な原因はN+1パターンです。リストを取得してからそのリレーションを1行ずつ取得すると、数百回のラウンドトリップが発生します。Raw SQLが有効なのは、手動チューニングが本当に必要なレポーティング・分析・計測されたホットパスクエリに対してです。
ORMが生成する実際のSQLを確認するにはどうすればよいですか?
各ツールは生成されたSQLを直接公開しています。Prismaはクエリログオプションを有効にしてクライアントをインスタンス化するとすべてのクエリをログに記録します。KyselyはSQLとパラメータを返すcompileメソッドを公開しています。DrizzleはクエリにtoSQLメソッドを公開しています。SQLを取得したら、PostgreSQLでEXPLAIN ANALYZEを実行して実行プランを確認し、インデックスが期待通りに使用されているかを検証します。
ORMプロジェクト内でRaw SQLを記述できますか?
はい、これは一般的な本番環境のパターンです。ほとんどのORMは生成クエリと並行してRaw SQLのエスケープハッチを提供しています。PrismaはTypedSQLを提供しています。これはtypedSqlプレビューフラグで有効化するプレビュー機能であり、.sqlファイルにSQLを記述し、生成された完全に型付きの関数を通じて呼び出すことができます。静的に型付けされているため、動的に生成されるWHERE句には対応しておらず、そのような場合は引き続き型なしのRawクエリメソッドが必要です。