Raw SQL in Application Code
When to write the statement yourself, how to keep it parameterized and findable, and what you take on when you do.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
When is hand-written SQL the right call, and how do I keep it from becoming an unmaintainable pile of strings?
The finance page needs revenue by month by segment, with a running total and last year's comparison. The ORM can express roughly half of it.
Write the query as a string in the handler, interpolate the tenant id and the date range, and move on. It is one endpoint.
The interpolated tenant id is an injection in the exact place that decides who sees whose money (SQL Injection).
- The interpolated tenant id is an injection in the exact place that decides who sees whose money (SQL Injection).
- A year later the column is renamed. The ORM models are updated by tooling; the string is not, and nothing fails until the endpoint is called.
- The statement has no test, because testing it requires a real database and it lives inside a request handler.
- Three more endpoints copy the pattern, each with its own slightly different tenant predicate, and one of them forgets it.
What is actually happening
- Raw SQL means you hand a statement and a parameter array to the driver. The driver sends them, the database plans and executes, rows come back as arrays or dictionaries.
- Parameter binding is a protocol feature, not string escaping: the statement text and the values travel separately, so a value can never change the statement's structure. This is why parameterization is a *structural* defence rather than a filter.
- Nothing maps the result. Column names, null handling, numeric types and date types are yours — and drivers differ, notably in returning fixed-precision numerics as strings to avoid float rounding.
- You gain access to everything the dialect can do: CTEs, window functions,
INSERT ... ON CONFLICT,RETURNING, lateral joins, set operations,FOR UPDATE SKIP LOCKED. - You also gain the ability to influence the plan, because you control the statement the planner sees (Cost-Based Optimization).
Binding is structural, escaping is a filter
The reason to insist on parameters is not that they are tidier. It is that the statement text and the values travel to the database as separate things, so no value — however crafted — can become part of the statement's structure. There is no edge case to get wrong because there is no parsing of the value at all.
Escaping, by contrast, is a function that must be correct for every encoding, quoting mode and character set the database supports. It can be right. It is a filter, and filters have gaps.
1// Injectable: the value becomes part of the statement2await db.query(`SELECT * FROM invoices WHERE tenant_id = '${tenantId}'`)3 4// Safe from injection, still wrong: any user can name any tenant5await db.query('SELECT * FROM invoices WHERE tenant_id = $1', [req.body.tenantId])6 7// Safe and correct: bound value, tenant from the verified principal8await db.query(9 'SELECT id, total, status FROM invoices WHERE tenant_id = $1 AND id = $2',10 [req.user.tenantId, req.params.id],11)The middle case is the one worth staring at: parameterization fixed injection and did nothing at all for authorization. They are different problems with different controls (Object-Level Authorization).
Give statements a name, a type and a test
Raw SQL becomes unmaintainable when it is anonymous. A statement inside a handler has no signature, no test and no discoverable relationship to the schema. The same statement as an exported function with typed parameters and a typed result is an ordinary unit of code that happens to be written in SQL.
This is also what makes the "mix ORM and raw SQL" recommendation workable rather than chaotic: the raw statements live in one place, are reviewed as a set, and are tested against a real database.
- 1Justify it
Name the dialect feature or round-trip saving that the ORM cannot give.
fails by Written by preference; now there are two data layers and no reason.
- 2Name and type it
One exported function, typed parameters, typed row.
fails by String inline in a handler — untestable, ungreppable.
- 3Parameterize
Every value bound; identifiers from a fixed map.
fails by One interpolation, usually the sort column or the tenant id.
- 4Bound the result
LIMIT, or a cursor, or a streaming read.fails by Whole table into process memory once the table grows.
- 5Test on a real engine
Same database and major version as production.
fails by SQLite in tests, Postgres in production — dialect differences pass silently (Test Against the Real Database).
- 6Tag for observability
Embed a route comment; export duration and row-count metrics.
fails by Slow query log full of anonymous statements nobody can attribute.
- 7Own the plan
Read
EXPLAINwhen it matters; recheck after data growth.fails by Plan flips to a scan at volume and nobody is watching (Reading EXPLAIN ANALYZE).
The statements worth writing by hand
SKIP LOCKED exists in MySQL 8.0+ and not in older versions; COPY is Postgres-specific with LOAD DATA INFILE as the MySQL analogue; WITH RECURSIVE is broadly available but MySQL only from 8.0.A short list covers most legitimate uses. Each of these is either impossible or clumsy through an object-oriented mapping layer, and each replaces several round trips or an application-side race with one statement the database executes atomically.
- Upsert —
INSERT ... ON CONFLICT DO UPDATE ... RETURNING, which removes the check-then-insert race entirely. - Atomic counters —
UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1, where the predicate makes the check and the write one operation (Atomic Operations). - Claim-a-row queues —
SELECT ... FOR UPDATE SKIP LOCKED LIMIT 10, the standard pattern for many workers over one table (Job Queues). - Analytical reads — window functions, running totals, grouped aggregates with comparisons across periods.
- Recursive traversal —
WITH RECURSIVEfor trees and graphs held relationally (Subqueries, CTEs, EXISTS, UNION, CASE). - Bulk writes — a single multi-row
INSERT, orCOPY, instead of thousands of statements. - Set-based maintenance — one
UPDATE ... WHEREinstead of loading and saving a million objects.
How to build it
Most important first.
- Give every statement a name and a home: a module of query functions with typed signatures, not strings inside handlers.
- Parameterize without exception. If a value cannot be a parameter — an identifier — it must come from a fixed map in code, never from input.
- Type the result explicitly at the boundary and convert there: numerics, dates, nullable columns. Do not let driver-shaped rows travel into the domain.
- Test against a real database of the same engine and version. A statement that uses dialect features cannot be meaningfully tested against a different one (Test Against the Real Database).
- Attach an application comment to expensive statements so they are identifiable in the database's own logs: a
/* route=reports.revenue */prefix survives intopg_stat_statementsand the slow query log. - Use
RETURNING(Postgres, SQLite, MariaDB) to avoid a second round trip after a write, where the dialect supports it.
What can go wrong
- One interpolation among a hundred parameterized statements. It only takes one, and code review is the only thing that catches it.
- Silent breakage after a schema change, discovered by users. Nothing type-checks a string.
- Dialect lock-in arriving quietly: three window functions and a
SKIP LOCKEDlater, the "we could switch databases" conversation is over. - Numeric precision bugs from treating a
NUMERICcolumn as a float because the driver did not. - A statement without
LIMITon a table that grew, returning millions of rows into process memory (Memory Leaks in Backend Services is the symptom people report). - An
INlist built by expanding placeholders in a loop, which is fine until the list is long enough to exceed the driver's parameter limit.
- A read-modify-write done as two statements races; done as one
UPDATE ... SET n = n + 1it does not, because the database applies it atomically under a row lock (Atomic Operations). INSERT ... ON CONFLICT DO UPDATE(Postgres) andINSERT ... ON DUPLICATE KEY UPDATE(MySQL) exist precisely to close the check-then-insert race, which application-side "does it exist?" logic cannot (Backend Races).SELECT ... FOR UPDATE SKIP LOCKEDis the standard way for several workers to claim rows from one queue table without colliding (Job Queues).
- Parameterize values; allow-list identifiers. Those two sentences are the whole security content of this lesson, and both are routinely violated under deadline.
- Multi-statement execution — sending
a; bin one call — should be disabled or avoided; it converts an injection from "read a row" into "drop a table". - Connect as a least-privileged database user. If the application never issues DDL, the user it connects as should not be able to (Database Privileges and Blast Radius).
- Never log a statement together with its bound parameters at info level. Parameters are the values, and values are frequently personal data (Secrets in Logs).
- "Raw SQL is dangerous." Parameterized raw SQL is exactly as injection-safe as an ORM. Concatenated SQL is dangerous — in any layer, including inside an ORM.
- "Raw SQL is faster." The same statement performs the same regardless of who typed it. It is more expressive, which usually means fewer statements.
- "Escaping input is enough." Escaping is a filter with edge cases; binding is structural. Use binding.
- "We use an ORM, so we do not have raw SQL." Check for the raw-fragment helpers. Almost every codebase has them, and that is where the injection is.
Operating it
- Named statements plus an embedded route comment make the database's slow query log joinable with application traces (Correlation Ids That Survive Every Hop).
- Statement-level metrics per named query — count, duration distribution, rows returned — because a hand-written query is a component with its own behaviour.
- Plan drift: the same statement can get a different plan when data volume or statistics change. That is Database Engineering's territory to analyse and yours to notice (Reading EXPLAIN ANALYZE).
- Hand-written SQL scales well by construction — you can see and control the round trips. What breaks is unbounded results, which grow with the table.
- At larger volumes the analytical statements are the first candidates to move off the primary, to a replica or a warehouse (Read Replicas From the Application).
- Team scale is the real limit: a shared file of two hundred statements needs conventions, naming and tests, or it becomes the thing the ORM was adopted to avoid.
- You get expressiveness and control; you pay with mapping code, refactoring risk and a schema coupling that no tool tracks.
- Dialect features are the reason to write it by hand and the reason it is not portable. Take the features knowingly.
- Real-database tests are slower than unit tests and are the only tests worth having here.
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALParameter binding, naming and result mapping apply to every driver and language.
- DATABASE-SPECIFICPlaceholder syntax and features diverge: Postgres uses
$1, MySQL and SQLite use?;RETURNINGexists on Postgres, SQLite and MariaDB but not MySQL;ON CONFLICTis Postgres/SQLite while MySQL spells itON DUPLICATE KEY UPDATEwith different semantics around which constraint matched. - LANGUAGE-SPECIFICSome ecosystems can type-check SQL against a live schema at build time (sqlc for Go, sqlx for Rust, PgTyped for TypeScript), which removes the "nothing checks the string" objection; most cannot, and the objection stands there.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.