When most developers start building backend applications, they're usually focused on one thing, getting an API working. Express routes, controllers, some auth, a bit of middleware, that's usually the whole mental model in the beginning.
But sooner or later, a bigger question shows up. How exactly should your application be talking to the database?
And that's when a bunch of unfamiliar words start floating around. Raw SQL, database drivers, ORMs, Prisma, Drizzle, query builders. Some people will tell you to never touch an ORM and just write SQL yourself. Others will swear Prisma made their life ten times easier. And then there's a smaller crowd that prefers Drizzle specifically because it stays close to SQL instead of hiding it.
So what's actually going on here? Why do all these tools even exist, and how do you know which one to reach for? Let's build this up from the ground.
First, understand how data actually moves
Before Prisma or Drizzle mean anything to you, it helps to see the full picture of how a request travels through a backend app.
Say a user opens your site and clicks something like "show my profile." That click travels roughly like this.
Browser
|
v
Express API
|
v
Database Layer
|
v
PostgreSQL Database
|
v
Response back to the user
That "database layer" bit in the middle is where all the confusion tends to live. Your app needs some way to actually talk to PostgreSQL, and there are basically three common ways to do that: writing raw SQL yourself, using an ORM like Prisma, or using a query builder like Drizzle.
Writing raw SQL directly
This is the oldest, most direct route there is, you just write the SQL yourself.
Say you've got a users table.
users
id
name
email
password
Finding a user is as simple as this.
SELECT *
FROM users
WHERE id = 1;
The database speaks SQL natively, so there's no translation happening anywhere.
In Node.js, if you're on PostgreSQL, the pg package is the usual way to send queries like that directly.
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
const result = await pool.query(
"SELECT * FROM users WHERE id = $1",
[1]
);
console.log(result.rows);
Your code sends SQL, PostgreSQL runs it, and the data comes straight back. Nothing hidden, nothing abstracted away.
Writing SQL yourself gives you complete control, obviously. You can write things like this without fighting any abstraction.
SELECT
users.name,
COUNT(orders.id)
FROM users
JOIN orders
ON users.id = orders.user_id
GROUP BY users.name;
For gnarly reports like that, SQL is genuinely hard to beat. Writing it yourself also forces you to actually understand joins, indexes, execution plans, and performance in general, which is knowledge that pays off no matter what tool you end up using later. And there's no extra layer sitting between your code and the database either.
But raw SQL has real downsides once a project grows past a handful of queries. Imagine a codebase with hundreds of them scattered around.
const result = await pool.query(
`
SELECT *
FROM users
WHERE email=$1
`,
[email]
);
Now imagine someone renames a column somewhere down the line, say email becomes email_address. Every query touching that column is now quietly broken, and the database knows about the change immediately. Your TypeScript code has no idea until something crashes at runtime.
There's a sneakier version of this problem too.
const user = result.rows[0];
console.log(user.emial);
Spot the typo, emial instead of email. JavaScript won't say a word about it. You only find out once that line actually executes and blows up. This exact pain point is basically why ORMs exist in the first place.
So what is an ORM, really
ORM stands for Object Relational Mapping, which sounds fancier than it actually is. All it really means is that there's a bridge sitting between your programming language and your database.
Instead of writing this.
SELECT *
FROM users
WHERE id=1;
You end up writing something closer to this.
user.findUnique({
id: 1
})
The ORM takes what you wrote and turns it into actual SQL behind the scenes.
Your TypeScript code
|
v
ORM
|
v
SQL query
|
v
Database
Developers reach for ORMs because modern apps need more than just sending queries around. They need type safety, proper migrations, schema management that doesn't require memorizing every table by hand, and honestly, just less repetitive boilerplate. That's the gap ORMs are trying to close.
In the TypeScript world today, the names that come up most are Prisma, Drizzle, TypeORM, and Sequelize. This piece focuses mainly on raw SQL, Prisma, and Drizzle, since those three are what most modern TypeScript backend projects are actually choosing between right now.
Getting into Prisma
Prisma is easily one of the most talked about ORMs in the TypeScript world, and it's usually one of the first names that comes up in any conversation about modern Node.js backends. So what problem is it actually solving?
At its core, Prisma lets your TypeScript app talk to a database using regular TypeScript code instead of SQL scattered everywhere. It supports PostgreSQL, MySQL, SQLite, SQL Server, and MongoDB.
TypeScript code
|
v
Prisma Client
|
v
SQL queries
|
v
Database
You write TypeScript, Prisma turns it into the actual queries.
A typical Express plus Prisma plus PostgreSQL setup ends up looking roughly like this.
Client
|
v
Express API
|
v
Service Layer
|
v
Prisma Client
|
v
PostgreSQL Database
Prisma Client is really the piece doing all the work here, it's the actual bridge between your backend code and the database sitting behind it.
Everything starts with a file called schema.prisma, which defines your entire database structure in one place.
model User {
id Int @id @default(autoincrement())
name String
email String @unique
password String
}
That's basically saying, create a User table with these four columns, and Prisma understands exactly what that means.
Where raw SQL would need something like this to create the table.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT,
email TEXT UNIQUE,
password TEXT
);
Prisma just needs the schema block shown above, and it handles turning that into actual database changes through something called a migration.
A migration is really just a recorded history of database changes over time. Say your User table starts out simple.
User
id
name
email
Months later you decide to add a phone number field. Instead of manually altering the table yourself, you run something like this.
npx prisma migrate dev --name add_phone_number
Prisma generates a migration file to track that change, something like a folder with 001_initial_setup followed by 002_add_phone_number, so your entire database history stays saved and versioned instead of living only in someone's memory.
Once your schema's defined, Prisma generates a client you actually use in your code.
const user = await prisma.user.findUnique({
where:{
id:1
}
});
Behind the scenes, that quietly becomes this.
SELECT *
FROM users
WHERE id=1;
You never had to type that SQL yourself.
The common operations map over pretty intuitively too. Creating a record that would normally be an INSERT INTO users (name, email) VALUES (...) becomes prisma.user.create({ data: { name, email } }). Fetching all users, which would be a plain SELECT * FROM users, becomes prisma.user.findMany(). Updating a record swaps UPDATE users SET name='Aman' WHERE id=1 for prisma.user.update({ where: { id: 1 }, data: { name: "Aman" } }). And deleting swaps DELETE FROM users WHERE id=1 for prisma.user.delete({ where: { id: 1 } }).
Where Prisma really earns its popularity is type safety. If your schema defines a User with id, name, and email, then writing something like this actually gives you working autocomplete on every field.
const user = await prisma.user.findMany();
user[0].email;
And if you accidentally typo it as user[0].emali, Prisma paired with TypeScript will flag that before your app even runs, which saves a genuinely surprising amount of debugging time down the line.
Real applications almost never have just one table either. Say a user has many posts.
User
id
name
Post
id
title
userId
Prisma represents that relationship directly in the schema.
model User {
id Int @id @default(autoincrement())
name String
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
userId Int
user User @relation(
fields:[userId],
references:[id]
)
}
And fetching a user along with their posts becomes a single readable call.
const user = await prisma.user.findUnique({
where:{
id:1
},
include:{
posts:true
}
});
Which gives you back something like a user object with a nested posts array, exactly the shape you'd want to work with in your app.
Prisma's biggest strengths really come down to how natural the developer experience feels, how strong the type safety is especially in TypeScript projects, how organized migrations become, how solid the documentation is, and how easily a whole team can look at the schema and immediately understand the database structure.
It's not without trade-offs though. Genuinely complex reporting queries can start feeling awkward compared to just writing raw SQL. Because Prisma hides SQL so effectively, developers who only ever learn Prisma can end up struggling later when they actually need to optimize something at the database level. And there's technically an extra layer sitting in the request flow, application to Prisma to SQL to database, instead of the more direct application to SQL to database path. For most apps that extra layer is a complete non issue, but it's worth knowing it's there.
Prisma tends to make the most sense when you're building APIs in TypeScript, want fast development, mostly need standard CRUD operations, and care a lot about developer experience. A lot of startups and SaaS products lean on it for exactly these reasons.
Where Drizzle fits into the picture
Drizzle is the other ORM that's picked up serious momentum in the TypeScript world, but understanding it really comes down to understanding one thing first, Prisma and Drizzle are built on genuinely different philosophies.
Prisma's whole approach is keeping developers away from database complexity as much as possible. Drizzle takes the opposite stance, keep developers close to SQL, just give them TypeScript's safety on top of it. Both are chasing the same end goal, a better development experience around your database, they just take very different roads to get there.
Drizzle is a lightweight TypeScript ORM that works with PostgreSQL, MySQL, and SQLite, and its whole focus is type safety, performance, staying close to SQL, and keeping the architecture lightweight.
TypeScript code
|
v
Drizzle ORM
|
v
SQL query
|
v
Database
The philosophy difference is easiest to see side by side. Fetching all users in Prisma looks like this, and you never really need to think about what SQL it's generating underneath.
const user = await prisma.user.findMany();
Drizzle asks you to write something noticeably closer to actual SQL.
const users = await db
.select()
.from(usersTable);
You can basically read what's happening at the database level just by looking at the code.
Schemas work differently too. Where Prisma defines things in its own schema.prisma file, Drizzle defines the schema directly in TypeScript.
import {
pgTable,
serial,
varchar
} from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
name: varchar("name"),
email: varchar("email")
});
So your database structure literally lives inside your TypeScript codebase, no separate schema language to learn.
The common operations feel like a TypeScript flavored version of SQL itself. Inserting a row that would be INSERT INTO users (name, email) VALUES (...) in SQL becomes db.insert(users).values({ name, email }) in Drizzle. Fetching everything, which is SELECT * FROM users, becomes db.select().from(users). And filtering, which would be SELECT * FROM users WHERE id=1, becomes db.select().from(users).where(eq(users.id, 1)).
People gravitate toward Drizzle for a handful of reasons. It's genuinely lightweight with very little unnecessary abstraction sitting in the way. Because it stays close to SQL, your actual SQL knowledge stays sharp and keeps growing rather than getting hidden behind an abstraction. The queries it generates tend to be predictable since there's less machinery translating your intent. And it was built with modern runtimes in mind too, things like edge environments and serverless setups.
That said, if you've never really learned SQL, Drizzle can feel a lot more confusing upfront than Prisma does, since Prisma is generally the easier on-ramp for total beginners. Using Drizzle well also genuinely requires understanding joins, relations, indexes, and how queries actually work, there's no hiding from that. And its ecosystem, documentation, and community, while solid, are still smaller than Prisma's more mature setup.
The simplest way to frame the difference is this. With Prisma you treat the database like a bunch of objects, prisma.user.findMany(), and the whole focus is developer experience. With Drizzle you treat the database more like SQL itself, db.select().from(users), and the focus shifts toward performance and control.
Looking at all three side by side
At this point we've covered three real approaches, raw SQL, Prisma, and Drizzle, and architecturally they stack up like this.
Raw SQL: Application → SQL Query → Database
Prisma: Application → Prisma Client → SQL → Database
Drizzle: Application → Drizzle → SQL → Database
Take one simple operation, finding a user by email, given a basic users table with id, name, and email. Here's how each approach handles the exact same task.
Raw SQL:
const result = await pool.query(
`
SELECT *
FROM users
WHERE email=$1
`,
["rahul@test.com"]
);
Prisma:
const user =
await prisma.user.findUnique({
where:{
email:"rahul@test.com"
}
});
Drizzle:
const user =
await db
.select()
.from(users)
.where(
eq(
users.email,
"rahul@test.com"
)
);
All three get you the exact same data back at the end of the day. What actually differs is how much control you want, how much abstraction you're comfortable with, and how much SQL you already know going in.
A mistake a lot of beginners make here is fixating on "which one's the fastest," when in real projects that's rarely the question that actually matters. The better questions are usually things like what the team's already comfortable with, how complex the project genuinely is, how strong everyone's database knowledge actually is, and how complicated the queries are likely to get. A simple SaaS app can usually get by just fine on Prisma alone. A database heavy application is where Drizzle, or even raw SQL in places, starts earning its keep.
| Feature | Raw SQL | Prisma | Drizzle |
|---|---|---|---|
| Learning curve | Harder | Easier | Medium |
| SQL Control | Highest | Lowest | High |
| Type Safety | Low | Excellent | Excellent |
| Performance | Excellent | Good | Excellent |
| Developer Experience | Medium | Excellent | Good |
| Complex Queries | Excellent | Medium | Excellent |
| Beginner Friendly | Low | High | Medium |
So what should you actually choose
Should you always default to using an ORM? No, honestly. An ORM is a tool like any other, and no single tool is the right call for every project out there. Some apps genuinely move faster with one, others are better off without.
Raw SQL earns its place when you need maximum control over the database, when the app is genuinely database heavy, when queries get complex, or when performance really can't take a hit. Think analytics platforms, reporting systems, financial applications, or anything doing heavy data processing. Say you need a report answering something like "how much has each customer purchased over the last five years, and what's their average order value." That kind of query is just naturally easier to express directly in SQL.
SELECT
customers.name,
COUNT(orders.id),
AVG(orders.amount)
FROM customers
JOIN orders
ON customers.id = orders.customer_id
GROUP BY customers.name;
There's really no substitute for SQL's raw power in cases like that. The catch is that leaning entirely on raw SQL across a large codebase gets unwieldy fast, hundreds of scattered query files, and you're left manually managing type safety yourself on top of it all.
Prisma tends to be the better call when you're building a TypeScript backend, doing mostly standard CRUD work, care about development speed, and have multiple developers working across the same codebase. Think SaaS products, admin dashboards, e-commerce APIs, or content management systems, basically anywhere operations like creating a user, updating a profile, creating an order, or pulling dashboard data are the daily bread and butter. Prisma genuinely boosts productivity here, turning something like a raw SELECT * FROM users WHERE id=1 into a clean, readable prisma.user.findUnique({ where: { id: 1 } }) that any teammate can understand at a glance.
Drizzle earns its spot when you're working in TypeScript, already know SQL reasonably well, care a lot about performance and control, and want something lightweight rather than heavy handed. It tends to appeal to developers who want the safety net of an ORM without feeling like they've been pulled too far away from the actual database.
In practice, plenty of real projects don't stick to just one approach. A startup might reasonably begin like this.
Express → Prisma → PostgreSQL
Which is a genuinely practical starting point. As the app grows and analytics or complex reporting needs pile up, it's common to start layering raw SQL into specific parts of the app rather than ripping out Prisma entirely.
Application
├── Prisma
└── Raw SQL Queries
↓
PostgreSQL
Using one approach everywhere isn't some rule you're required to follow, and most real world codebases end up mixing them anyway. Startups often lean toward TypeScript plus Prisma plus PostgreSQL purely for development speed. Newer, more modern TypeScript projects increasingly reach for Drizzle instead, valuing that lightweight, SQL friendly approach. And larger scale systems often end up combining an ORM with raw SQL and dedicated database optimization work, simply because different parts of a large system run into genuinely different problems.
Mistakes worth avoiding
A few things trip people up consistently here. The first is treating the ORM itself as the database. Prisma isn't a database, Drizzle isn't a database, they're just tools for accessing one, whether that's PostgreSQL, MySQL, or MongoDB sitting underneath.
The second is skipping SQL entirely and jumping straight to Prisma. It feels productive early on, but it tends to backfire later when slow queries stop making sense, joins feel needlessly confusing, and database optimization becomes genuinely hard because there's no real foundation underneath the abstraction. An ORM was never meant to replace SQL, it's a layer sitting on top of it.
The third is chasing raw performance numbers above everything else. Developer productivity matters just as much in the real world. Shaving five milliseconds off a query while making development three months slower isn't automatically the smarter trade to make.
A learning path worth following
If starting from zero today, this is roughly the order worth going in. Start with SQL fundamentals, tables, primary keys, foreign keys, joins, indexes, transactions, all using something like PostgreSQL. Then spend some real time connecting directly with something like Node's pg package, so you actually understand how an application talks to a database without anything hidden in between. From there, move into Prisma, get comfortable with schemas, migrations, relations, and the type safety it brings. And finally, give Drizzle a proper try, so you understand what an SQL-first approach with a query builder actually feels like, along with the performance trade-offs that come with it.
Where that leaves you
The point of an ORM was never to replace SQL. It's there to make the developer experience better, nothing more grand than that.
A genuinely strong backend developer isn't someone who just happens to know Prisma or Drizzle well. It's someone who understands how a database actually works, how to write SQL when it's needed, what an ORM is actually simplifying for them, and just as importantly, when reaching for an ORM makes sense and when writing raw SQL directly is the smarter call.
Once that clarity sets in, choosing between these tools stops feeling like a guessing game. You start making the call based on what the project actually needs, not on whatever's trending that particular month.
Top comments (0)