Skip to main content
← Back to Data & Storage

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 $queryRaw template literal or Prisma.sql — no string concatenation with application data, ever. $queryRawUnsafe is 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 include code and meta for 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

  • $queryRaw and $executeRaw accept template literals; values interpolated via template syntax (${value}) are auto-parameterized — safe
  • Prisma.sql builds 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 $queryRawUnsafe and verify every dynamic part is from an allowlist or constant
  • Identify every ${value} in $queryRaw template 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 $queryRaw should be $queryRaw<T>(...) where T is the row shape
  • Without <T>, the return is unknown[] and downstream code that does result.id compiles to unknown.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 returns bigint (Prisma types as bigint); annotate accordingly: $queryRaw<{ count: bigint }>(...)
  • For aggregate results, lowercase the column name in the type to match Postgres default (SELECT COUNT(*) returns count, not COUNT)

Pattern Recognition: When Prisma Suffices

  • Compound WHERE with multiple conditions: Prisma where: { AND: [...] } works
  • JOIN with conditions: Prisma include or select with relation filtering works
  • GROUP BY with aggregates: Prisma groupBy({ by, _count, _sum, _avg }) works
  • ORDER BY with multiple keys including relation fields: Prisma supports
  • LIMIT / OFFSET: Prisma take / 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 with ts_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 ANALYZE for diagnostics

$transaction & Raw SQL Interaction Checklist

  • $queryRaw and $executeRaw work inside $transaction(async tx => ...) — call tx.$queryRaw instead of prisma.$queryRaw
  • Mixing raw and ORM in one transaction: works, both go through the same connection; isolation level applies to both
  • For INSERT ... RETURNING ..., $queryRaw returns the rows; useful when you need IDs after a bulk insert that Prisma's createMany doesn't return

Error Handling Checklist

  • Raw errors are wrapped in Prisma.PrismaClientKnownRequestError for some cases, Prisma.PrismaClientUnknownRequestError for others
  • Raw errors don't always include the same code and meta as 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.empty for 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.sql for 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$queryRawUnsafe with string-concatenated user input (SQL injection); raw query with no type annotation returning data the application uses (silent type errors)
    • High$queryRaw patterns 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.sql composition; missing type annotations on result rows
    • Low — Cosmetic raw query formatting; raw queries that could be slightly cleaner with Prisma.empty etc.
    • Inverse (Over-Refactored) — ORM rewrites that produce slower queries than the raw they replaced; complex Prisma.sql composition where a single inline raw query would be clearer
  • 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 --version and the docs. Don't recommend rewriting performance-critical raw queries without measuring the Prisma equivalent first. Verify Prisma version — features like groupBy, orderBy with relations, cursor pagination 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 $queryRawUnsafe ever — even for legitimate dynamic SQL, allowlist + Prisma.sql composition 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.

  1. Raw Query Inventory
File:Line Type SQL (truncated) Inputs Source Type Annotation? Justified? Severity
  1. Parameterization Safety Findings — Per call site: parameterization mechanism, injection risk if any, recommended fix ($queryRaw template, Prisma.sql, allowlist for dynamic identifiers)

  2. Type Annotation Findings — Missing <T> annotations; downstream consumers losing type safety; recommended type per call site

  3. Should-Be-Prisma Findings — Per call site: the equivalent Prisma call, verification the generated SQL is comparable, refactor cost

  4. Justified-Stays Findings — Per call site: the reason raw is needed (window function, RECURSIVE CTE, vendor-specific, performance), recommended improvements (comment, type annotation, Prisma.sql composition)

  5. $queryRawUnsafe Findings — Per call site: every unsafe usage, the safe rewrite (Prisma.sql for parameterizable parts + allowlist for non-parameterizable), severity Critical

  6. Prisma.sql Composition Findings — Raw queries built with string concatenation that should use Prisma.sql\...`andPrisma.join([...])` instead

  7. Error Handling Findings — Raw queries without try/catch, missing surfacing of meaningful errors, missing rollback awareness in transactions

  8. Logging & Observability Findings — Missing query logs in dev, untraced spans, undocumented intent

  9. Vendor-Specificity Findings — Raw queries that lock the codebase into Postgres; documentation of the assumption

  10. Common-Pattern Refactor Findings — Raw queries matching the canonical "should be Prisma" patterns from the table; the specific replacement

  11. Over-Refactored Findings — ORM rewrites that would lose performance or correctness; complex Prisma.sql where inline raw is clearer

  12. 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).

Need help applying this to a real product?

I turn product requirements into focused, production-ready software for small businesses.