Skip to content
4 min read

Drizzle or raw SQL: picking a data layer

Query builders promise type safety, raw SQL promises control. What each actually costs in a TypeScript service, and the hybrid most teams end up with.

On this page
  1. Two populations
  2. The case for a builder
  3. The other side of it
  4. When to write the SQL
  5. Migrations are the part that bites
  6. Pick by population

The argument gets framed as types versus control, which is why it never resolves. Both sides are describing real experiences of different parts of the same codebase.

Here is the framing I find more useful. A data layer has two populations of queries, and they want opposite things.

Two populations

The first is large and dull. Fetch a row by id. Insert a record. Update three fields. Paginate a list with two filters. These are numerous, nearly identical, and they change shape every time somebody adds a column.

The second is small and sharp. The dashboard query with three CTEs and a window function. The bulk upsert with conflict handling. The report that needs an index hint and an EXPLAIN plan read twice before anyone trusts it.

A query builder is excellent at the first and merely adequate at the second. Raw SQL is the reverse. Teams that pick one for everything spend the rest of the project paying for the half it fits badly, and they usually blame the tool.

The case for a builder

With Drizzle the schema is TypeScript and the types fall out of it.

src/db/schema.tsts
import { pgTable, bigserial, varchar, timestamp, jsonb } from "drizzle-orm/pg-core";

export const lead = pgTable("lead", {
  id:        bigserial("id", { mode: "number" }).primaryKey(),
  source:    varchar("source", { length: 32 }).notNull(),
  sourceRef: varchar("source_ref", { length: 64 }).notNull(),
  payload:   jsonb("payload").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
});

export type Lead = typeof lead.$inferSelect;
export type NewLead = typeof lead.$inferInsert;
ts
const rows = await db
  .select({ id: lead.id, source: lead.source })
  .from(lead)
  .where(eq(lead.source, "web"))
  .limit(20);
// rows: { id: number; source: string }[] — inferred, not declared

Three things there are worth more than they look.

Renaming a column breaks the build, rather than producing a 500 at 2am from one forgotten string in a file nobody has opened this year. That is the strongest argument for a typed layer and it gets stronger with every person who joins the team.

The selected shape is the returned type. Select two columns and the type has two fields, with no hand-written interface drifting quietly away from the table it claims to describe.

And there is no string building, which matters because conditional filters are both the classic injection surface and the classic source of a stray AND. Composed predicates make that whole category structural instead of a thing you have to be careful about.

The other side of it

A second dialect to know. You still need SQL, plus how this particular library expresses SQL, and when the generated query is wrong you are debugging in two languages at once.

Awkwardness at the edges. Window functions, recursive CTEs, DISTINCT ON, full-text ranking. All expressible, and the builder version is usually longer and harder to read than the SQL it generates.

And an API that makes N+1 comfortable. Fetching related rows in a loop reads perfectly naturally in TypeScript and is a disaster against the database. The builder did not cause that, it just declined to make it look as expensive as it is.

When to write the SQL

Drop down when the query needs the database's own vocabulary. Keep it parameterised and give it a name.

src/db/reports.tsts
export async function leadsByDay(from: Date, to: Date) {
  const { rows } = await db.execute<{ day: string; total: number; running: number }>(sql`
    SELECT date_trunc('day', created_at)::date AS day,
           count(*)                            AS total,
           sum(count(*)) OVER (ORDER BY date_trunc('day', created_at)) AS running
      FROM lead
     WHERE created_at >= ${from} AND created_at < ${to}
     GROUP BY 1
     ORDER BY 1
  `);
  return rows;
}

That function is honest about three things at once. It is SQL. The parameters are bound rather than interpolated. And the result type is stated explicitly, because nothing can infer it.

It also lives in the data layer beside the builder queries, not scattered through route handlers, which is the part people skip.

The rule I actually use: if I expect to read an EXPLAIN plan for it, I write it as SQL. Tuning a query you did not write is guesswork, and the plan is the only thing that settles it.

Migrations are the part that bites

Whatever you choose for querying, generated migrations deserve more suspicion than they get.

Generate the SQL, then read it before it runs. A schema change that looks like a rename in TypeScript can come out the other side as a drop and an add, which is a column of production data gone and present in nobody's test database.

Check the generated file into the repository, review it in the pull request like any other code, and apply it with the same ceremony as a deploy. That a tool wrote it is not a reason to trust it more than code a person wrote.

Pick by population

The builder for the hundred boring queries. SQL for the handful that need the database to actually be a database.

That hybrid is not indecision, though it gets called that. It is the arrangement that gives you compile-time safety exactly where schema drift hurts, and full expressiveness exactly where the alternative is fighting an abstraction to produce a query you could have typed in a minute.

Frequently asked

Is Drizzle better than writing raw SQL?

For the repetitive majority of queries, yes — it gives you types derived from the schema, refactors that fail at compile time rather than at runtime, and no string concatenation. For complex analytical queries it adds a layer between you and a language that already expresses them well. Most production services end up using both, deliberately.

Does a query builder slow down my queries?

Not meaningfully by itself; it generates SQL and sends it. The performance problems blamed on query builders are usually N+1 patterns made easy by a convenient API, or queries that look cheap in TypeScript and are expensive in the database. The generated SQL is what runs, so read it when a query is slow.

How do I handle migrations with Drizzle?

Define the schema in TypeScript and generate SQL migration files from it, then review those files before applying. The important discipline is that the generated SQL is checked in and reviewed like any other code, because that file is what will run against production, and a generated drop is still a drop.

When should I drop to raw SQL in a typed codebase?

When the query needs the database's own vocabulary: window functions, CTEs, full-text search, recursive queries, bulk upserts with conflict handling, or anything you expect to tune with EXPLAIN. Keep it parameterised, keep it in a named function next to the rest of the data layer, and type the result explicitly.

Found this useful?Share on XShare on LinkedIn

Related reading