The thing that finally pushed me off Prisma wasn't a feature I was missing. It was a prisma generate that I forgot to run, a stale client, and twenty minutes of squinting at a type error that had nothing to do with the actual bug. A codegen step that sits between you and the truth is a small tax, and you pay it every single day. Drizzle removed that tax, and once it was gone I realized how much of my mental overhead it had been quietly consuming.
This site, the one you're reading on, runs Drizzle against a Supabase Postgres instance. So this isn't a "I read the README and got excited" note. It's the ORM that backs the database I have to keep alive in production, and that context has taught me both why I like it and where it makes you grow up fast.
The schema is just TypeScript, and that's the whole point
In Drizzle, your schema is a TypeScript file. Not a .prisma DSL that compiles into a client, not a YAML, not annotations on entity classes. Plain TS that you import like any other module.
import { pgTable, serial, text, timestamp, integer } from "drizzle-orm/pg-core";
export const visitors = pgTable("visitors", {
id: serial("id").primaryKey(),
email: text("email").notNull(),
source: text("source"),
visits: integer("visits").notNull().default(1),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
Because that's just an object, the types flow out of it for free. There's no generated client to keep in sync, the table definition is the type. When I add a column, my queries are type-checked against it the instant I save the file. No build step, no "did I regenerate?" doubt. tsc either passes or it doesn't, and it's reading the same source I'm editing.
On the database I keep alive in production for this site, working as a working student at BMW while finishing my M.Sc. CS at LMU Munich, this is the property I lean on hardest. Here is what a real schema plus a typed query actually looks like, annotated line by line:
export const users = pgTable("users", {
id: serial("id").primaryKey(),
email: text("email").notNull().unique(),
});
const row = await db
.select()
.from(users)
.where(eq(users.email, input));pgTable describes the table in code you can read and refactor. There is no separate schema language to learn, the types you define here are the types your queries return.
The annotation makes the point concrete: every constraint and every type traces back to a single line of code I can read. That is the property that pays off every day, not just on the day you set the schema up.
That single property, schema as the literal source of truth, with zero codegen between definition and use, is the reason Drizzle stuck. Everything else is nice. This is the part that changed my day-to-day.
The whole pitch hides in one number. Predict it:
SQL-first means you write SQL, and you should be glad
Drizzle's query builder doesn't hide SQL from you. It mirrors it. If you know the query you want, you can usually guess the Drizzle method, because they named things after the SQL keyword rather than inventing a friendlier abstraction.
import { eq, desc } from "drizzle-orm";
import { db } from "./db";
import { visitors } from "./schema";
const recent = await db
.select()
.from(visitors)
.where(eq(visitors.source, "newsletter"))
.orderBy(desc(visitors.createdAt))
.limit(10);
recent is fully typed, narrowed to exactly the columns you selected, with the right nullability. And when I want something the builder doesn't express cleanly, there's a sql template tag that drops me to raw SQL without losing type inference for the result. No escape hatch shame, no fighting the abstraction.
The difference in how you define a table is where the philosophy shows. Same visitors table, both ORMs:
// schema.prisma, a separate DSL
model Visitor {
id Int @id @default(autoincrement())
email String
source String?
visits Int @default(1)
createdAt DateTime @default(now())
}
// then, every schema change:
// prisma generate ← the step you WILL forget
// types come from a generated client, not your sourcePrisma compiles a DSL into a client you must keep in sync. Drizzle's schema is the source tsc already reads.
This is also the honest tradeoff, so I'll state it plainly: Drizzle expects you to know SQL. It does less hand-holding than Prisma's relation traversal. If you don't already think in joins and indexes, Prisma's higher-level API will carry you further before you hit a wall. Drizzle assumes you want the wall to be visible. I do, I'd rather see the query I'm shipping than trust a magic resolver to produce a sane one, but that's a real preference and not everyone shares it.
This is the honest tradeoff, not a sales pitch. Here is when each tool is the right call:
When you already think in joins and indexes and you want to see the query you are shipping. The schema is plain TypeScript, there is no codegen step, and the builder reads like SQL.
It assumes you want the wall to be visible. I do, because on a database I keep alive in production I would rather read one extra line than trust a resolver to produce a sane query.
Migrations, and the discipline they demand
Schema changes go through drizzle-kit. You edit the TS schema, generate a migration, and apply it:
bunx drizzle-kit generate # diffs the schema, writes a .sql file under drizzle/
bunx drizzle-kit migrate # applies pending migrations to the DB
The generated SQL lands in a drizzle/ folder as plain, reviewable files. I like that I can read exactly what's about to hit the database before it does, no opaque shadow-database dance, just SQL I can eyeball in the diff.
But here's the part I learned the hard way, and it's not Drizzle's fault, it's a process truth that any migration tool will quietly enforce. My deploys do not apply migrations automatically. Merging the PR that adds the migration file does nothing to the production database. You have to run migrate against the prod connection string yourself, by hand, every time.
I know this because I once didn't. A migration added a column, the PR merged, the code shipped expecting that column, and the column wasn't there. Every API route that read it started failing, and because the failure was downstream of a query, the error message pointed everywhere except the actual cause. It took down a visitor-email pipeline before I traced it back to a drizzle/ file that had never been applied. Now the rule in my head is simple: merging schema is not the same as migrating, and the gap between those two moments is where outages live. Drizzle gives you clean, explicit migrations; it's on you to actually run them.
Before you read the next section, test the mental model that footgun trains. Take a guess:
If you guessed wrong, you are exactly the person the next section is for.
The fix for that footgun is not cleverness, it is a checklist you run every single time:
Run those five steps in order and the outage I described stops being possible, no matter how tired you are.
The generate-vs-apply boundary is a feature, not a gap
People sometimes ask why Drizzle does not just apply migrations on deploy like some frameworks do. I have come to see the split as deliberate, and I am glad it exists. generate writes the SQL artifact. migrate runs it against a specific database. Keeping those two as separate, explicit actions means nothing touches production until I point a command at the production connection string on purpose. There is no hidden hook deciding to alter my tables during a build.
The risk that makes this matter is the quiet one. A migration file gets merged, the types update, CI goes green, and everyone assumes the database changed. It did not. The first sign is an API route reading a column that is not there yet, and the error surfaces far from the cause. I have lived this once, and the failure was downstream of a query, so the message pointed everywhere except the unapplied migration sitting in drizzle/.
The discipline that keeps me safe is boring and reliable: I treat "the migration exists in the repo" and "the migration ran on prod" as two separate facts, and I never let a deploy declare victory on the first one. After I merge anything that touches the schema, I run drizzle-kit migrate against the prod URL by hand, then confirm the column is actually there. Drizzle does not hide the boundary, so I do not get to pretend it is not there.
Make the migration ritual boring on purpose
The antidote to a merged-but-unapplied migration is not vigilance, because vigilance fails exactly when you are busy and a deploy feels routine. The antidote is a fixed, mechanical sequence I run the same way every time: edit the schema, generate the migration, read the SQL, commit it in the PR, then after merge run migrate against the prod URL and confirm the column landed. Five steps, always the same order, no judgment calls in the middle of them.
Making it boring is the whole point. A ritual you do not have to think about is one you cannot half-skip under pressure, and the column that takes down a pipeline is always the one someone skipped because they were sure it had already happened. When the steps are mechanical, "did the migration run on prod" stops being a thing I remember and becomes a thing I checked. That is the difference between a process that protects you and a habit that protects you right up until the day you are tired. I would rather be bored than paged.
Relations without the magic
The objection I hear most from people coming off Prisma is about relations. Prisma's headline trick is traversing them, user.posts.comments and it quietly assembles the joins for you. Drizzle does support relations, and the query API can fetch nested data, but it never hides the cost of doing so, and that's the part I've grown to value rather than resent.
In practice I lean on two things. For the common case I describe relations in the schema and use the relational query builder, which gives me typed nested results without hand-writing the join. For anything with a sharp performance edge, a report, a hot path, a query I want to be able to read in the slow-query log and recognize, I write the join explicitly with the core builder, because then the SQL that leaves my machine is the SQL I wrote. The escape hatch isn't a failure mode here; it's the default posture. Drizzle assumes I'd rather see one extra line than trust a resolver to guess a sane query, and on a database I have to keep alive in production, that's exactly the trade I want. The magic version is lovely right up until it generates an N+1 you didn't ask for and can't easily see.
What the first week actually looks like
If you're coming from a codegen ORM, the loop is shorter than you expect, and the shortness is the whole pitch. You add a column to the schema file. You run drizzle-kit generate, which diffs the schema against the last migration and writes a new .sql file you can actually read. You eyeball that file, it's plain SQL, no shadow-database ceremony, and then drizzle-kit migrate applies it. The instant the schema file is saved, tsc is already type-checking your queries against the new shape; there's no separate "did I regenerate the client?" step sitting between you and the truth, because the schema is the client.
To make that loop concrete, here is the exact sequence of commands I run when I add a column, with the output you actually see in the terminal:
The third command is the one your deploy will not run for you, which is the whole point of the next paragraph. The thing that bites newcomers isn't Drizzle itself, it's that this shortness can lull you. The migration file is generated and committed, the types update immediately, everything feels applied, but generating SQL and running it against production are two different events, and the gap between them is exactly where I once took down a pipeline. So the first habit to build alongside the loop is the boring one: treat "the migration exists" and "the migration ran on prod" as separate facts you verify separately. The tooling makes the local loop so tight that it's easy to forget the deploy doesn't share that tightness.
Prototyping with push, shipping with generate
One thing that took me a moment to internalize is that drizzle-kit has two modes for getting schema into a database, and they are for two different phases of work. There is push, which shoves your current schema straight at a database and reconciles the difference without writing a migration file, and there is generate, which produces the reviewable .sql migration I described above. Early on I treated them as competing options and picked one. They are not competing. They are tools for different moments.
When I am prototyping locally, throwing tables around, renaming a column three times in an afternoon, figuring out the shape, push is the right tool. I do not want a migration file for every exploratory change I am going to undo in ten minutes. I want the local database to track my schema file with zero ceremony so I can iterate at the speed of thought. The migration history at that stage would just be noise, a record of indecision nobody will ever need.
The moment the shape settles and the change is headed for production, I switch to generate. Now I want the artifact: the plain SQL file, committed, reviewable, applied deliberately against prod by hand. The discipline that matters in production is exactly the discipline that gets in the way during prototyping, and having both modes means I do not have to choose between fast iteration and safe deploys. I use the loose one while the cost of a mistake is zero and the strict one when the cost of a mistake is an outage. Mixing those up, using push against production or hand-writing migrations for throwaway local tables, is how you get either a scary deploy or a miserable afternoon. Match the tool to the stakes of the moment and both phases stay pleasant.
Indexes are your job, and that is fine
Drizzle will not quietly add an index for you. There is no heuristic watching your queries and deciding a column deserves a B-tree. If you want one, you declare it in the schema next to the column it covers, the same way you declare everything else:
export const visitors = pgTable(
"visitors",
{
id: serial("id").primaryKey(),
email: text("email").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [index("visitors_email_idx").on(t.email)],
);
When I first hit this I half-expected it to feel like a missing feature. It is the opposite. An ORM that silently creates indexes is also one that can silently create the wrong ones, an index on a column you never filter by, a single-column index where the query wanted a composite, a duplicate of one Postgres already gave you with the primary key. You inherit those guesses without ever seeing the decision, and they cost write throughput and disk for queries that do not exist. The failure mode is invisible: a slow query you cannot account for is far worse than one index you had to type out.
Making indexing a visible decision keeps it honest. The line lands in the same drizzle/ migration I already eyeball before it touches prod, so adding an index is a thing I chose, reviewed, and applied, not a thing that happened to me. On adatepe.dev that matters, because the same Supabase Postgres I have to keep alive is where a bad index quietly taxes every insert.
A concrete one from this site: the visitors table backs the email pipeline, and the hot read is a lookup by email to decide insert-or-increment on every beacon. Before I added visitors_email_idx that was a sequential scan, and at a few thousand rows it was already a few milliseconds per request, the kind of number that is invisible in dev and ugly under a burst of traffic. The B-tree turned it into an index scan that explain analyze reports in well under a millisecond, and the only cost was one declared line plus a touch of write amplification on insert, which on a write-light table I never feel. The point is not that the index was clever; it is that I got to weigh that exact trade myself instead of inheriting it. The same instinct runs through the whole /blog/modern-webdev-stack-for-solo-founders-2026 writeup: pick the thing where the cost is visible at the moment you pay it. A composite index is where this bites hardest, because the column order has to match the query's filter-then-sort shape, and an ORM that guessed it for you would get that wrong constantly and silently. Declaring it by hand means the order is a decision I can read in the diff, not a coincidence I have to reverse-engineer from a slow-query log six months later.
And here is the part I find satisfying: the SQL literacy Drizzle already assumes is exactly the literacy that lets you index well. If you can read the join you wrote and recognize it in the slow-query log, you can see which column the planner is scanning and put the index where it belongs. No magic to add them means no magic hiding the ones you got wrong.
Why it fits the rest of my stack
The other reason Drizzle won is that it doesn't fight the tools I'd already chosen. It runs natively on Bun, which is my runtime. It's the same TypeScript-strict, no-magic philosophy as the rest of what I build, the same instinct that made me pick a server-rendered Next.js setup over something heavier. The whole stack reads like one decision repeated: prefer the thing where I can see what's happening over the thing that's convenient until it isn't.
I'm not going to tell you Drizzle is faster by some benchmark I made up, or that it cut my query times by a number I can't defend. I haven't measured that and I'm not going to pretend I have. What I can say honestly is that it removed a category of friction, the codegen step, the generated-client drift, that I didn't fully notice until it was gone, and it did so without taking SQL away from me. That's the trade I want.
When Prisma or raw SQL is the better call
I would be lying if I said Drizzle wins every argument, so here is the honest other side. Prisma has the richer ecosystem by a wide margin, and Prisma Studio is genuinely nice. Being able to open a GUI, browse rows, and edit a record without writing a query is a real productivity win, especially when you are onboarding someone who does not yet live in the database. Drizzle Studio exists and has gotten better, but Prisma's tooling has more years and more polish behind it, and pretending otherwise would be silly.
There is also the team-shape question. If most of the people touching the data layer do not think in SQL, Prisma's DSL and its relation traversal are a kindness. The higher-level API carries people further before they hit a wall, and on a team that values a uniform, declarative model file over the freedom to drop to a raw join, Prisma's opinions are the right opinions. A tool that does more hand-holding is the correct choice when hand-holding is what the team actually needs.
And then there is raw SQL, which beats any ORM the moment the query gets gnarly enough. A reporting query with window functions, a recursive CTE, a carefully tuned hot path where I want to control exactly what the planner sees, those are cases where reaching for an ORM abstraction adds friction rather than removing it. I would rather write the SQL, read it, and own it. I have written more about choosing tools to match the work in the modern webdev stack for solo founders, and the same instinct applies here.
If you are still unsure which side of this you land on, walk the choice instead of reading my verdict:
Drizzle, Prisma, or raw SQL for your next project?
Answer two or three honest questions and I'll point you at the right tool and the right next read.
Do you already think in joins and indexes without flinching?
So why does Drizzle still win for me? Because for the work I actually do, a solo-maintained Postgres I keep alive while working as a working student at BMW and finishing my M.Sc. CS at LMU Munich, the codegen tax and the generated-client drift cost me more daily friction than Prisma's nicer Studio saves. I already think in SQL, so the hand-holding is overhead I do not need, and Drizzle's sql escape hatch gives me the raw-SQL door without leaving the type system. It is the right fit for my constraints, not a universal verdict.
If you live in TypeScript, write your own SQL without flinching, and you're tired of a generate step standing between your schema and your types, Drizzle is worth a weekend. Start with one table, wire up drizzle-kit, and feel how short the loop is. And if you want to see the rest of the stack it sits inside, that's the thread running through everything on my /cv and the work in my /#projects. I'll keep writing up the pieces as I go on the /blog.
What's your relationship with SQL?
Pick the honest one, I'll point you at the right next read.
Drizzle has earned a permanent spot in my data layer, and if you want to see it carrying real queries day to day, my projects are the honest proof.