This article is published in English.
Drizzle or Prisma? Check the Join Type and the Logged SQL Before You Pick
Model the same users and invoices tables in Drizzle and Prisma, compare the join result types and logged SQL, and catch driver mappings that turn totals into strings.
ORM debates usually run on download charts and conference slogans, yet the question that bites in production is much smaller: when you join a user to their invoices, what type does total have, and can you read the SQL that produced it? This guide builds the same two tables in Drizzle and in Prisma, runs one insert and one join in each, and compares the inferred TypeScript types, the logged queries, the migration output and the behaviour under Node's native TypeScript execution. You will come away with a short, repeatable lab that answers the ORM question for your own codebase instead of someone else's benchmark. For a broader decision framework that also weighs raw SQL, see how to choose a database layer between raw SQL, Prisma and Drizzle.
What each tool is optimising for
The two libraries make different promises. Drizzle offers query code that reads like SQL written in TypeScript, with no separate query engine process and a design that suits edge runtimes. Prisma offers a schema-first workflow with a dedicated schema.prisma file and a generated client; its recent release line has been moving its query engine away from Rust and toward TypeScript. That engine transition was still in progress at the time of writing, so check the current Prisma release notes for which engine your version uses.
Popularity cuts both ways as well: Prisma still leads on installs, while Drizzle dominates the growth conversation. None of that tells you anything about your join. The two criteria used below are deliberately narrow and practical: whether total arrives as a number, and whether the SQL in the log is something you would be comfortable pasting into psql during an incident.
The scenario is a small invoicing app where an /invoices page must render a total. Something in the stack has to give that total a type, and that is where the comparison starts.
The same two tables, twice
Set up two separate project folders against the same PostgreSQL instance, and give each its own schema name. Sharing tables between two ORMs invites double-written rows that look like performance data but are really bugs.
In Drizzle, the schema lives in src/schema.ts as ordinary TypeScript. Notice that column names are declared explicitly in snake_case (user_id) while the property is camelCase (userId), and that the foreign key is a function reference to users.id:
import { integer, pgTable, uuid, varchar } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: varchar("email", { length: 255 }).notNull().unique(),
});
export const invoices = pgTable("invoices", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").notNull().references(() => users.id),
total: integer("total").notNull(),
});
In Prisma, the same model goes into prisma/schema.prisma. The relation is declared on both sides: User has an invoices array, and Invoice carries the scalar userId plus a @relation that ties it back:
model User {
id String @id @default(uuid())
email String @unique
invoices Invoice[]
}
model Invoice {
id String @id @default(uuid())
userId String
total Int
user User @relation(fields: [userId], references: [id])
}
Now the query that matters: fetch a user's invoices by email. Drizzle expresses it as an explicit inner join with a where clause, while Prisma asks for the user and says "include the invoices":
// drizzle
const rows = await db
.select()
.from(invoices)
.innerJoin(users, eq(invoices.userId, users.id))
.where(eq(users.email, email));
// prisma
const user = await prisma.user.findUnique({
where: { email },
include: { invoices: true },
});
The result types reflect those two mental models. Drizzle returns rows shaped like the join, with a users key and an invoices key on each row. Prisma returns User & { invoices: Invoice[] }, a nested object. Both are correct. Drizzle's shape mirrors the SQL; Prisma's shape mirrors the page you are about to render.
With query logging on, the difference continues. Drizzle's output is a join a developer can read directly. Prisma's output is perfectly usable, but it is generated SQL that you would not want to hand-edit.
Migrations were uneventful for a schema this small. drizzle-kit generate produced SQL files that can be committed; prisma migrate produced its own migration history that can also be committed. Neither tool had trouble with two tables, and a schema this size cannot reveal the harder migration cases, so no conclusion should be drawn there.
Reproducing the lab locally
Install each toolchain in its own folder. Drizzle needs the ORM, a driver (here postgres) and drizzle-kit for migrations; Prisma needs the CLI and the client, then prisma init to scaffold the schema file:
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit
pnpm add prisma @prisma/client
pnpm exec prisma init
In each folder, insert one user and two invoices, run the join once, and print the first invoice's total alongside its runtime type. Note the different access paths: rows[0].invoices.total for Drizzle's join rows versus user.invoices[0].total for Prisma's nested object.
console.log(rows[0]?.invoices.total, typeof rows[0]?.invoices.total);
console.log(user?.invoices[0]?.total, typeof user?.invoices[0]?.total);
If one tool reports string and the other number, the cause is almost always the database driver's type mapping, not the ORM's philosophy. PostgreSQL drivers commonly return bigint and numeric columns as strings to avoid losing precision in a JavaScript number, while plain integer columns come back as numbers. A string total is how "1200" + 50 quietly becomes "120050" on an invoice. Record the typeof result before choosing a library.
Running the query file with native TypeScript
Next, check whether the code runs directly under Node's built-in type stripping, which executes .ts files by erasing type annotations without a separate build:
node src/query.ts
A Drizzle module made only of functions and type annotations ran without trouble. A Prisma client generated into node_modules also ran when called from a small wrapper. The problem appeared with a file that imported Prisma's generated enums in the older style. TypeScript enum declarations are not just types; they compile to runtime objects, and Node's strip-only mode cannot erase them, so execution fails. That is not a Prisma defect, it is the nature of generated runtime code. If your version of Prisma uses the newer TypeScript-based engine and generator, inspect what prisma generate actually emits before assuming this still applies, and pin the version you tested.
Turning on query logging
Guessing at SQL is how incidents drag on. Both libraries can log every query, Drizzle via a logger option and Prisma via the log array on the client:
const db = drizzle(client, { logger: true });
const prisma = new PrismaClient({ log: ["query"] });
Put the two logged SQL strings next to the two typeof total results. Those four lines are the whole dataset this lab needs.
Where each tool costs you
The trade-offs surfaced in five places.
Types. Prisma's include produced exactly the shape the /invoices page wanted. Drizzle's join produced exactly the shape you want when debugging why a total doubled. Both are valuable on different days, which is an argument for picking one per database, not for running both against the same tables.
SQL visibility. When a total looks wrong, Drizzle's log settles the argument faster because the query is legible. When a newer team member needs to add a field, Prisma's schema file is the faster path. These are different moments with different winners.
The generate step. Prisma requires prisma generate after every schema change; Drizzle requires that schema.ts stays accurate. The generate step is easy to forget in CI, and a client one version behind the schema is a confusing failure. Make CI fail when generation is skipped.
Edge runtimes. Drizzle's edge-friendliness is a real selling point, but it only matters if you deploy to an edge runtime. A Node process sitting next to PostgreSQL on a VPS gains nothing from it, so do not let that argument decide a server-hosted app.
Bundle boundaries. Neither ORM belongs in a Client Component. If either one is imported into a "use client" module, such as an interactive table filter, the client boundary has been drawn too high and a database driver is heading to the browser. The article on drawing the use client boundary correctly covers how to fix that.
An itemised bill
Breaking the costs down further:
- Time. Drizzle's friction was the result shape of a join:
rows[0].invoices.totalorrows[0].totaldepending on how the select was written. Prisma's friction was remembering to regenerate after each schema edit. - Runtime behaviour. Both inserted, both joined, both returned two invoices. Two tables will never crown a winner.
- Enums. Prisma's generated enums are runtime values. Executing those files through raw Node type stripping fails; either compile that package or avoid running generated sources directly.
- Lock-in. Prisma's client is a product with its own generator and engine; Drizzle's tables are plain TypeScript. Leaving either after a year means rewriting the query layer, not flipping a setting. Put that in the RFC before anyone writes "we can always switch later".
Choosing, and what not to do
Choose Drizzle when you want SQL visible in code review and the team already thinks in joins. Keep the schema in schema.ts and make sure someone on the team is comfortable reading innerJoin.
Choose Prisma when the team's habits are built around schema.prisma and include. Budget for the generate step in CI and fail the pipeline when it has not run.
Avoid these regardless of the choice:
- Running both ORMs against the same production tables "to compare". That is how a total gets written twice and someone spends a day reconciling invoices against the bank.
- Choosing on weekly downloads. Choose on whose join result type you can read quickly under pressure.
- Importing the database client into a Server Action and also into a Client Component for convenience. That convenience is exactly how a client island ends up shipping a driver.
A string total that was really a driver problem
A realistic failure shows why the typeof check matters. A team models the same two tables in both tools, joins a user to two invoices, and logs the type of total: number in both cases. A week later, a different driver is introduced that maps a numeric column to string, and a report starts concatenating instead of adding, doubling the figures it displays.
The tempting fix is to wrap Number(total) around every call site. That hides the mapping rather than fixing it, and the next column with the same issue will slip through. The durable fix is to log the SQL and the result type once per library, pin the driver version, and never let two ORMs write to the same production tables.
Enum handling follows the same logic: generated enums are runtime code, so compile that package and run the output from dist/ rather than executing generated TypeScript directly. And whichever library wins, write the decision and the reason in the README, so that nobody adds the other one later just to try it.
Recording the environment before you compare
Results like these are only meaningful alongside the versions that produced them. The reference environment here was Node 24, TypeScript 7 and Next.js 16.3, running a small four-route invoices app. Keep a notes/lab.md file in the repository and start by capturing the three versions:
node -v
pnpm exec tsc -v
pnpm exec next --version
Write them at the top of the note. If a major version differs from the one a guide assumes, stop and reconcile before running anything else, because later commands will mislead you in quieter ways.
Then start the dev server and walk the routes:
pnpm exec next dev
Visit /, /invoices, /invoices/1, /settings, then /invoices again, with "Preserve log" enabled in DevTools. Capture the filter box together with the URL; that pairing is often the evidence you need later.
Then run the type checker and print its exit code:
pnpm exec tsc --noEmit --pretty false
echo $?
An exit code of zero is not a feature, only permission to move on to runtime checks. After that, run the commands from the lab section above on your own machine rather than trusting these results; hardware, memory pressure and whatever the browser is doing can shift memory usage, type-check duration and fetch timings more than a framework minor version would.
It also helps to keep a one-line "failed fix" entry in the note, in the form "tried X, still saw Y". That line turns the file into an honest lab record instead of a brochure, and it is the most useful thing to hand a colleague who picks the investigation up.
Mistakes worth avoiding
Three missteps from this kind of comparison recur often:
- Running both migration tools against the same database to compare them, which leaves two migration histories and one table under two names. The only clean recovery is a restore from backup.
- Importing generated Prisma enums into a file that is then run through Node's type stripping, which fails for the reasons above. Compile that package instead.
- Judging the libraries on download counts, which have no bearing on the type of your join.
Checklist before adding an ORM
- One ORM per database.
- The SQL for your key join logged at least once.
- The runtime
typeofof money columns logged at least once, and again after every driver upgrade. - Generated output containing enums is compiled, never executed through raw type stripping.
- The README names the chosen library and the reason.
Wrapping up
Two tables are not a production schema, and this lab did not benchmark thousands of joins or deploy to an edge runtime. What it does show is that the decisive differences are concrete and checkable in an afternoon: the shape of the join result, the readability of the logged SQL, the cost of the generate step, and whether a driver hands you numbers or strings. Pick one library per database, write down why, and make the typeof total check a ritual after every driver bump. Letting two migration tools manage one database ends in a restore, so keep that experiment well away from any system that handles real money.