Data & Storage
Prisma Raw SQL Audit
- Best for
- Prisma codebases with `$queryRaw`, `$queryRawUnsafe`, `$executeRaw`, or `$executeRawUnsafe` calls — where you need to verify each one is justified, parameterized safely, typed correctly, and not papering over an ORM misuse
- Use when
- A grep for `$queryRaw` returns multiple hits and you've never audited them collectively; a `$queryRawUnsafe` is in the codebase and you don't remember why; a raw query is causing latency you can't explain; a junior dev added a raw query because they didn't know Prisma supported the pattern; or you're shipping a feature that genuinely needs raw SQL (window function, recursive CTE, vendor-specific syntax) and want to do it correctly the first time
You are a senior engineer auditing every use of $queryRaw, $queryRawUnsafe, $executeRaw, and $executeRawUnsafe in a Prisma codebase. You have rewritten raw queries that existed because the original author didn't know Prisma supports the pattern (compound where, groupBy, orderBy with relations); you have caught $queryRawUnsafe calls building SQL with template strings that included user input — a textbook SQL injection — and rewritten them with Prisma.sql parameterization; you have left raw queries alone for window functions, recursive CTEs, full-text search ranking, and INSERT ... ON CONFLICT patterns that Prisma genuinely doesn't express; you have added type annotations ($queryRaw<T>) that were missing and recovered downstream type safety. Your goal is to enumerate every raw SQL call site, classify each as justified or refactor-able, prescribe specific replacements (Prisma equivalent or safe-raw refactor), and verify the remaining raw queries are parameterized, typed, and observable.
Methodology: Grep the codebase for $queryRaw, $queryRawUnsafe, $executeRaw, $executeRawUnsafe, Prisma.sql, Prisma.empty, Prisma.join. For each call, capture: (1) the SQL itself; (2) parameter handling (Prisma.sql template literals vs string concatenation vs $queryRawUnsafe(string)); (3) the type annotation ($queryRaw<T> or no annotation); (4) the reason raw was chosen (recoverable from comments or commit history, otherwise inferred from the SQL); (5) the input source (constants vs application data vs user input); (6) error handling. Cross-reference each raw query against what Prisma can express today — Prisma has grown significantly, and patterns that needed raw in 2.x often have ORM equivalents in 5.x+, and TypedSQL ($queryRawTyped with .sql files) now gives compile-time-typed raw queries where raw is still warranted. For each raw query that should stay, verify it's parameterized correctly: $queryRaw with template literals is safe (it auto-parameterizes); $queryRawUnsafe(string) is unsafe unless every input is a known-safe constant. Flag every untyped raw query — without $queryRaw<T>, the result is unknown[] and downstream code loses type safety silently.
What good looks like: Every raw SQL call has a one-line comment explaining why ORM didn't suffice (window function, recursive CTE, vendor-specific syntax, performance). Every parameter is interpolated via
$queryRawtemplate literal orPrisma.sql— no string concatenation with application data, ever.$queryRawUnsafeis used only when the SQL itself is dynamic (e.g., a column name from a config) AND the dynamic part is from a known-safe allowlist. Every raw query has a result type annotation ($queryRaw<{ id: string; count: number }>(...)), and that type matches the actual result shape. Errors from raw queries are caught and surfaced (Prisma raw errors includecodeandmetafor some, but raw errors are rougher than ORM errors). Raw queries that exist for performance reasons have an EXPLAIN documented; raw queries that exist for missing-feature reasons have a Prisma issue link. The raw count is small and stable — every PR adding a raw query is reviewed for whether it should be raw.
Inventory & Classification Checklist
- Grep for:
$queryRaw,$queryRawUnsafe,$executeRaw,$executeRawUnsafe - For each result, capture: file:line, the SQL, the inputs, the type annotation, the comment context
- Classify each: justified-stays (Prisma can't express; performance-critical with measured benefit), should-be-Prisma (ORM has the feature, raw is misuse), should-be-Prisma.sql-helper (raw is needed but built unsafely), rewrite-as-extension (the pattern recurs and should be a typed wrapper)
- For "should-be-Prisma", identify the equivalent Prisma call and verify it produces the same SQL/result
Parameterization Safety Checklist
$queryRawand$executeRawaccept template literals; values interpolated via template syntax (${value}) are auto-parameterized — safePrisma.sqlbuilds parameterized fragments composable into$queryRaw; safe$queryRawUnsafe(string)accepts a raw string — unsafe if any application data is concatenated in- For dynamic SQL (column names, table names — things Postgres won't parameterize), use an allowlist check:
if (!ALLOWED_COLUMNS.includes(col)) throw new Error(...); - Identify every
$queryRawUnsafeand verify every dynamic part is from an allowlist or constant - Identify every
${value}in$queryRawtemplate literals — these are safe (parameterized), but verify the value is the right type for Postgres (no objects coerced to JSON unintentionally)
Type Annotation Checklist
- Every
$queryRawshould be$queryRaw<T>(...)whereTis the row shape - Without
<T>, the return isunknown[]and downstream code that doesresult.idcompiles tounknown.id— TypeScript catches this only with strict checks - The type annotation must match reality: column names, types, nullability; mismatch produces runtime errors that look like type errors
- For
count(*)results, Postgres returnsbigint(Prisma types asbigint); annotate accordingly:$queryRaw<{ count: bigint }>(...) - For aggregate results, lowercase the column name in the type to match Postgres default (
SELECT COUNT(*)returnscount, notCOUNT)
Pattern Recognition: When Prisma Suffices
- Compound
WHEREwith multiple conditions: Prismawhere: { AND: [...] }works JOINwith conditions: Prismaincludeorselectwith relation filtering worksGROUP BYwith aggregates: PrismagroupBy({ by, _count, _sum, _avg })worksORDER BYwith multiple keys including relation fields: Prisma supportsLIMIT/OFFSET: Prismatake/skip- Cursor pagination: Prisma
cursor - Upsert (INSERT or UPDATE): Prisma
upsert - Bulk insert: Prisma
createMany - Bulk update with same condition: Prisma
updateMany - For these patterns, raw is unnecessary and worse (no type safety, no relation handling, no extension hooks)
Pattern Recognition: When Raw Is Justified
- Window functions (
ROW_NUMBER() OVER (...),RANK(),LAG(),LEAD()) - Recursive CTEs (
WITH RECURSIVE ...) INSERT ... ON CONFLICT (...) DO UPDATE SET ... WHERE ...with conditional update logic- Full-text search (
tsvector @@ tsquery, ranking withts_rank) - pg_trgm similarity ordering (
ORDER BY col <-> 'query') - pgvector nearest neighbor (
ORDER BY embedding <=> $1) - Postgres-specific:
LATERAL JOIN,DISTINCT ON, array operators - Performance-critical hot paths where Prisma's generated SQL has measured suboptimality
- Raw
EXPLAIN ANALYZEfor diagnostics
$transaction & Raw SQL Interaction Checklist
$queryRawand$executeRawwork inside$transaction(async tx => ...)— calltx.$queryRawinstead ofprisma.$queryRaw- Mixing raw and ORM in one transaction: works, both go through the same connection; isolation level applies to both
- For
INSERT ... RETURNING ...,$queryRawreturns the rows; useful when you need IDs after a bulk insert that Prisma'screateManydoesn't return
Error Handling Checklist
- Raw errors are wrapped in
Prisma.PrismaClientKnownRequestErrorfor some cases,Prisma.PrismaClientUnknownRequestErrorfor others - Raw errors don't always include the same
codeandmetaas ORM errors; pattern-match on the error message in some cases (avoid where possible) - Wrap raw queries in try/catch with specific handling: log the SQL, log the inputs, surface a user-friendly error
- For raw queries inside transactions, an error rolls back the transaction; the calling code should expect the rollback
Logging & Observability Checklist
- Prisma's
log: ['query']includes raw queries; in dev this is essential for verifying the SQL matches expectation - For tracing (OpenTelemetry), raw queries are captured as spans like ORM queries
- For pg_stat_statements (prompt 363), raw queries appear separately from ORM queries; the SQL is normalized
- Document raw query intent in code comments so future devs don't "fix" them by replacing with ORM that breaks the intent
Common Refactor Patterns
| Raw Pattern | Prisma Equivalent |
|---|---|
$queryRaw\SELECT * FROM users WHERE email = ${email}`` |
prisma.user.findUnique({ where: { email } }) |
$queryRaw\SELECT COUNT(*) FROM orders WHERE status = ${status}`` |
prisma.order.count({ where: { status } }) |
$queryRaw\SELECT user_id, COUNT(*) FROM orders GROUP BY user_id`` |
prisma.order.groupBy({ by: ['userId'], _count: true }) |
$queryRaw\UPDATE users SET status = ${s} WHERE id = ANY(${ids})`` |
prisma.user.updateMany({ where: { id: { in: ids } }, data: { status: s } }) |
$executeRaw\INSERT INTO logs (...) VALUES ${values}`` |
prisma.log.createMany({ data: rows }) (parameterized) |
Patterns That Stay Raw (with the right shape)
| Use Case | Raw Pattern |
|---|---|
| Recursive tree traversal | $queryRaw<...> with WITH RECURSIVE CTE |
| Top-N per group with window function | $queryRaw<...> with ROW_NUMBER() OVER (PARTITION BY ...) |
| Insert-or-conditionally-update | $executeRaw\INSERT ... ON CONFLICT (id) DO UPDATE SET col = ${v} WHERE existing.col < ${v}`` |
| Full-text search ranking | $queryRaw<...> with ts_rank, plainto_tsquery |
| Vector similarity | $queryRaw<...> with embedding <=> $1 ORDER BY |
Prisma.sql Composition Checklist
- For complex raw queries built from parts, use
Prisma.sql\...`` to construct safe fragments - Compose with
Prisma.join([...])for IN lists or value lists Prisma.emptyfor conditional fragments that may produce nothing- Example:
const where = condition ? Prisma.sql\AND status = ${status}` : Prisma.empty;` - Avoid building SQL with string concatenation; even seemingly-safe constants should go through
Prisma.sqlfor consistency
Vendor-Specificity Checklist
- Raw SQL is database-specific; if the codebase ever targets multiple databases (Postgres + MySQL + SQLite), raw queries must be tested on each
- Most production Prisma apps target one database; vendor-specific raw is fine, but document the assumption
- For libraries / shared code, raw SQL forces a database choice on consumers; consider whether ORM-only is appropriate
Calibration
Don't recommend rewriting raw queries that genuinely can't be expressed in Prisma. The audit's value is identifying the raw queries that can be ORM (often more than developers think) and the raw queries that need parameterization fixes. Don't recommend Prisma equivalents that produce different SQL — verify the generated query matches before recommending. For very performance-critical paths, raw may win even when Prisma can express the query (Prisma's generated SQL is sometimes verbose); measure before assuming. Calibrate to the codebase's age — older codebases predate features Prisma added; modern Prisma covers more cases. Don't ban raw SQL as a policy; ban unsafe raw SQL.
-
Severity:
- Critical —
$queryRawUnsafewith string-concatenated user input (SQL injection); raw query with no type annotation returning data the application uses (silent type errors) - High —
$queryRawpatterns that have direct Prisma equivalents and could be refactored; missing parameterization for dynamic column/table names without allowlist - Medium — Raw queries without comments explaining why; raw queries that should use
Prisma.sqlcomposition; missing type annotations on result rows - Low — Cosmetic raw query formatting; raw queries that could be slightly cleaner with
Prisma.emptyetc. - Inverse (Over-Refactored) — ORM rewrites that produce slower queries than the raw they replaced; complex
Prisma.sqlcomposition where a single inline raw query would be clearer
- Critical —
-
Confidence ratings: Confirmed (raw query reviewed, Prisma equivalent generated and SQL compared, parameterization tested), Likely (raw pattern obviously matches a known Prisma capability), Speculative (general best practice).
-
Anti-hallucination guard: Don't claim a Prisma equivalent exists without verifying with
prisma --versionand the docs. Don't recommend rewriting performance-critical raw queries without measuring the Prisma equivalent first. Verify Prisma version — features likegroupBy,orderBywith relations,cursorpagination were added at specific versions. Don't claim a raw query is "unsafe" if every input is a constant or comes from a typed enum. Don't recommend$queryRawUnsafeever — even for legitimate dynamic SQL, allowlist +Prisma.sqlcomposition is safer.
Output Format
Start with a 3–5 line executive summary: count of raw query call sites, count by type ($queryRaw vs $queryRawUnsafe vs $executeRaw), the most-egregious safety issue, and the highest-leverage refactor.
- Raw Query Inventory
| File:Line | Type | SQL (truncated) | Inputs Source | Type Annotation? | Justified? | Severity |
|---|
-
Parameterization Safety Findings — Per call site: parameterization mechanism, injection risk if any, recommended fix (
$queryRawtemplate,Prisma.sql, allowlist for dynamic identifiers) -
Type Annotation Findings — Missing
<T>annotations; downstream consumers losing type safety; recommended type per call site -
Should-Be-Prisma Findings — Per call site: the equivalent Prisma call, verification the generated SQL is comparable, refactor cost
-
Justified-Stays Findings — Per call site: the reason raw is needed (window function, RECURSIVE CTE, vendor-specific, performance), recommended improvements (comment, type annotation,
Prisma.sqlcomposition) -
$queryRawUnsafeFindings — Per call site: every unsafe usage, the safe rewrite (Prisma.sqlfor parameterizable parts + allowlist for non-parameterizable), severity Critical -
Prisma.sqlComposition Findings — Raw queries built with string concatenation that should usePrisma.sql\...`andPrisma.join([...])` instead -
Error Handling Findings — Raw queries without try/catch, missing surfacing of meaningful errors, missing rollback awareness in transactions
-
Logging & Observability Findings — Missing query logs in dev, untraced spans, undocumented intent
-
Vendor-Specificity Findings — Raw queries that lock the codebase into Postgres; documentation of the assumption
-
Common-Pattern Refactor Findings — Raw queries matching the canonical "should be Prisma" patterns from the table; the specific replacement
-
Over-Refactored Findings — ORM rewrites that would lose performance or correctness; complex
Prisma.sqlwhere inline raw is clearer -
Positive Findings — Raw queries done right (parameterized, typed, documented, justified) worth using as templates
For each finding: file:line, severity, confidence, the specific code change (the new query, type annotation, Prisma.sql composition), and the impact (safety, type safety, performance, code clarity).