Overview
Prisma’s query builder covers the vast majority of CRUD operations. It falls short for recursive CTEs, window functions, full-text search ranking, lateral joins, and any query that needs plan hints or custom operators. $queryRaw and $executeRaw give you a direct lane to the database while keeping the TypeScript type system and parameterized query safety intact. Drop down to raw SQL deliberately, not as a first resort.
Use $queryRaw for SELECT and $executeRaw for writes
The two methods differ in return value, not in safety.
// $queryRaw: returns typed rows.
const rows = await prisma.$queryRaw<Array<{ id: string; rank: number }>>`
SELECT id, ts_rank(search_tsv, plainto_tsquery(${query})) AS rank
FROM articles
WHERE search_tsv @@ plainto_tsquery(${query})
ORDER BY rank DESC
LIMIT ${limit};
`
// $executeRaw: returns affected row count.
const count = await prisma.$executeRaw`
UPDATE jobs SET status = 'running', started_at = now()
WHERE id = ${jobId} AND status = 'pending';
`- The tagged template syntax (backtick with interpolation) automatically parameterizes values. Each
${variable}becomes a$1,$2, … placeholder in the final SQL. - The type parameter on
$queryRaw<T>is not validated at runtime. Define it accurately; Prisma casts the returned rows directly toT. - Both methods work inside
$transaction. Pass the transaction client (tx) instead of the globalprisma. See prisma-transactions.
Always use the tagged template form, never string concatenation
String concatenation produces injection vulnerabilities. The tagged template form is the safe path.
// WRONG: SQL injection risk. Never do this.
const raw = `SELECT * FROM users WHERE email = '${userInput}'`
await prisma.$queryRawUnsafe(raw)
// CORRECT: parameterized.
await prisma.$queryRaw`SELECT * FROM users WHERE email = ${userInput}`$queryRawUnsafeand$executeRawUnsafeaccept a plain string. Use them only when the query structure itself must be dynamic (for example, a column name you build from an allowlist). In that case, validate the dynamic part against a hardcoded list before interpolating.- Never pass user input as a column name, table name, or SQL keyword. Parameterization only protects values, not identifiers.
Use Prisma.sql to compose query fragments
When a query has conditional clauses, use Prisma.sql to assemble fragments safely.
import { Prisma } from "./generated/prisma/client"
function buildFilter(status?: string): Prisma.Sql {
return status ? Prisma.sql`WHERE status = ${status}` : Prisma.sql``
}
const filter = buildFilter(req.query.status)
const rows = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT id FROM jobs ${filter} ORDER BY created_at DESC LIMIT 50;
`Prisma.sqlfragments compose without re-opening injection surfaces. Each fragment’s interpolated values are parameterized independently.Prisma.join(array, separator)produces comma-separated parameter lists forINclauses:WHERE id IN (${Prisma.join(ids)}).Prisma.raw(string)injects a literal SQL fragment with no parameterization. Use only for identifiers validated against an allowlist.- In Prisma 7, import
Prismafrom the generator’soutputpath (for example./generated/prisma/client), not@prisma/client. The$queryRaw,$executeRaw,Prisma.sql,Prisma.join, andPrisma.rawAPIs are otherwise unchanged. See prisma-v6-to-v7-upgrade.
Know when to drop to raw SQL
The ORM is appropriate for most CRUD. Drop down to raw SQL when the query builder cannot express the query without a workaround.
Queries that require raw SQL in Prisma:
- Recursive CTEs (
WITH RECURSIVE). - Window functions (
ROW_NUMBER() OVER (PARTITION BY ...)). - Full-text search with ranking (
ts_rank,ts_headline). See full-text search. JSONBpath operators (@>,#>>,jsonb_path_query). See postgres-jsonb.EXPLAINorEXPLAIN ANALYZEoutput. See postgres-explain.- Upsert with conflict target expressions that go beyond
@uniquefields. - Bulk insert via
COPYorINSERT ... ON CONFLICT DO UPDATEwith complexSETexpressions.
If you find yourself calling $queryRaw frequently for the same table, consider whether the schema lacks an index or a materialized view that would let the ORM query handle it.
Map raw results back to Prisma types when possible
$queryRaw bypasses the Prisma model layer. The result rows will not include DateTime conversion or nested relation objects.
type RankedArticle = {
id: string
title: string
rank: number
}
const results = await prisma.$queryRaw<RankedArticle[]>`
SELECT id, title, ts_rank(search_tsv, plainto_tsquery(${q})) AS rank
FROM articles
WHERE search_tsv @@ plainto_tsquery(${q})
ORDER BY rank DESC
LIMIT 10;
`- Postgres returns
BigIntforcount(*)and similar aggregates. Cast toNumberexplicitly if your JSON serializer cannot handleBigInt. DateTimecolumns come back as JavaScriptDateobjects. This matches the Prisma client behavior, so downstream code is compatible.- When a raw query returns the same shape as a Prisma model, prefer the ORM query unless performance profiling shows a clear gain. See postgres-explain for query plan analysis.