Home / Articles

Raw SQL, Prisma or Drizzle: Choosing a Database Layer for Type Safety and Control

How raw SQL, Prisma and Drizzle differ in control, type safety and developer experience on PostgreSQL, and a practical way to pick one for a TypeScript app.

Publisher: REACTAPP.TOP15 min read3403 words

This article is published in English.

Sources: Prisma vs Drizzle vs Raw SQL: What Every Backend Developer Should Understand Before Choosing an ORM

Most backend work starts with getting an API to answer. You wire up routes, add a controller or two, bolt on authentication and some middleware, and for a while that's the whole picture. Then a harder question shows up: how should the application actually talk to its database? Suddenly there's a pile of terms in the air (raw SQL, drivers, ORMs, query builders, Prisma, Drizzle) and strong opinions in every direction. Some engineers won't touch an ORM. Others say Prisma saved them weeks. A third group picks Drizzle because it keeps SQL visible instead of hiding it. The sections below build the topic up from first principles, so that picking between raw SQL, Prisma and Drizzle for a TypeScript and PostgreSQL stack ends up as an engineering call about control and type safety, not fashion.

How a request reaches the database

Before any tool name means anything, it helps to trace how a request travels through a typical backend. Someone visits your site and taps a control such as "show my profile." That action begins in the browser and makes its way down through your API, into a database layer, and finally to the database itself, before a response makes the return trip.

Browser
   |
   vExpress API   |
   vDatabase Layer   |
   vPostgreSQL Database   |
   vResponse back to the user

The database layer in the middle is where the confusion concentrates. Your application needs some mechanism to communicate with the database, and there are three common ones: writing raw SQL by hand, using an ORM such as Prisma, or using a query builder such as Drizzle. Each of the sections below takes one of those in turn.

Writing SQL by hand

The most direct route is also the oldest: you write the SQL yourself. Imagine a simple users table with the usual columns.

users
id
name
email
password

Retrieving a single user is then as plain as it gets.

SELECT *
FROM users
WHERE id = 1;

Because the database speaks SQL as its native language, nothing is being translated on the way in or out. In Node.js on PostgreSQL, the pg package is the usual way to send queries directly. You import a connection pool from it.

import { Pool } from "pg";

Then you configure the pool from an environment variable and run a parameterized query against it, reading the resulting rows.

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 executes it, and the rows come straight back with nothing abstracted away in between. Writing SQL yourself gives you total control. Complex reporting, for instance, is expressed directly, such as joining users to their orders and grouping to count each user's orders.

SELECT
users.name,
COUNT(orders.id)
FROM users
JOIN orders
ON users.id = orders.user_id
GROUP BY users.name;

For gnarly analytical queries like that, hand-written SQL is genuinely hard to improve on. Writing it also forces you to understand joins, indexes, execution plans, and performance in general, and that knowledge stays valuable no matter which tool you adopt later. There is also no extra layer sitting between your code and the engine.

The downsides show up once a project grows past a handful of queries. Picture a codebase with hundreds of them scattered throughout, including plenty that look like this lookup by email.

const result = await pool.query(
`
SELECT *
FROM users
WHERE email=$1
`,
[email]
);

Now suppose someone renames a column, turning email into email_address. Every query touching that column silently breaks. The database registers the change immediately, but your TypeScript code has no idea until something fails at runtime. There is a subtler version of the same hazard. Say you grab the first row.

const user = result.rows[0];

Then you read a property from it, with a typo.

console.log(user.emial);

The mistyped emial produces no complaint from JavaScript. You only discover it when that line runs and blows up in front of a user. This exact category of pain is the reason ORMs exist at all.

What an ORM really is

ORM stands for Object Relational Mapping, which sounds grander than it is. It simply means there is a bridge between your programming language and your database. Instead of writing a query like this by hand,

SELECT *
FROM users
WHERE id=1;

you write something closer to a method call on an object.

user.findUnique({
 id: 1
})

The ORM takes that and produces the real SQL behind the scenes, so your code flows through the ORM to a generated query and then to the database.

Your TypeScript code
        |
        vORM        |
        vSQL query        |
        vDatabase

Teams reach for ORMs because modern applications want more than a way to fire off queries. They want type safety, real migrations, schema management that does not depend on memorizing every table, and far less repetitive boilerplate. In today's TypeScript world the names that come up most are Prisma, Drizzle, TypeORM, and Sequelize. The focus here stays on raw SQL, Prisma, and Drizzle, because those three cover what most modern TypeScript backends are genuinely weighing.

Getting to know Prisma

Prisma is among the most discussed ORMs in the TypeScript ecosystem and is often the first name mentioned in any conversation about modern Node.js backends. At its heart it lets a TypeScript application talk to a database through ordinary TypeScript rather than SQL strewn everywhere, and it supports PostgreSQL, MySQL, SQLite, SQL Server, and MongoDB. You write TypeScript, and Prisma turns it into queries.

TypeScript code
        |
        vPrisma Client        |
        vSQL queries        |
        vDatabase

A typical Express, Prisma, and PostgreSQL setup layers cleanly, with requests entering through the API, passing a service layer, and reaching the database by way of Prisma Client.

Client
  |
  vExpress API  |
  vService Layer  |
  vPrisma Client  |
  vPostgreSQL Database

Prisma Client is the piece doing the real work: the actual bridge between your backend code and the database behind it. Everything begins with a single file, schema.prisma, which describes the entire database structure in one place. A user model, for example, declares four typed columns.

model User {
  id       Int    @id @default(autoincrement())
  name     String
  email    String @unique
  password String
}

That block tells Prisma to create a User table with those columns, and Prisma understands exactly what it implies. Where raw SQL would need an explicit statement to create the same table,

CREATE TABLE users (
 id SERIAL PRIMARY KEY,
 name TEXT,
 email TEXT UNIQUE,
 password TEXT
);

Prisma needs only the schema block above and handles translating it into real database changes through a migration. A migration is essentially a recorded history of how the database changed over time. Suppose your user table starts out minimal.

User
id
name
email

Months later you want to add a phone number. Rather than altering the table by hand, you run a single command.

npx prisma migrate dev --name add_phone_number

Prisma generates a migration file to capture that change, so the folder grows from an initial setup migration to one that adds the phone number, and the full history stays versioned rather than living in someone's memory. Once the schema is defined, Prisma generates a client you use in code, for example to fetch one user by id.

const user = await prisma.user.findUnique({
  where:{
    id:1
  }
});

Behind the scenes that quietly becomes ordinary SQL.

SELECT *
FROM users
WHERE id=1;

You never had to type the SQL yourself. The everyday operations map over intuitively as well: an insert becomes a create call, fetching everything becomes a find-many call, updating a record becomes an update call keyed by a where clause, and deleting becomes a delete call. Where Prisma most earns its reputation is type safety. If your schema defines a User with an id, a name, and an email, then querying gives you real autocomplete on each field.

const user = await prisma.user.findMany();
user[0].email;

Mistype that as user[0].emali and Prisma together with TypeScript flags it before the application ever runs, which saves a surprising amount of debugging over time. Real applications rarely have a single table, of course. Say a user owns many posts, so you have a users side and a posts side related by a user id.

User
id
namePostid
title
userId

Prisma expresses that relationship directly in the schema, on both the user model,

model User {
 id Int @id @default(autoincrement())

and the post model, which points back to its user through a relation.

 name String posts Post[]
}model Post { id Int @id @default(autoincrement()) title String userId Int user User @relation(
 fields:[userId],
 references:[id]
 )}

Fetching a user together with their posts then becomes one readable call that includes the related records.

const user = await prisma.user.findUnique({
 where:{
  id:1
 },
 include:{
  posts:true
 }
});

That returns a user object with a nested array of posts, precisely the shape you want in application code. Prisma's strengths come down to how natural the developer experience feels, how strong its type safety is in TypeScript projects, how orderly migrations become, how solid its documentation is, and how quickly a whole team can read the schema and grasp the database structure.

It is not free of trade-offs. Genuinely complex reporting can feel awkward next to hand-written SQL. Because Prisma hides SQL so effectively, developers who learn only Prisma sometimes struggle later when they need to optimize at the database level. And there is technically one more hop in the request path, from application to Prisma to SQL to database, rather than the shorter application-to-SQL-to-database route. For most applications that extra layer is a non-issue, but it is worth knowing it exists. Prisma tends to fit best when you are building TypeScript APIs, want fast development, mostly need standard create-read-update-delete work, and care a great deal about developer experience, which is why many startups and SaaS products lean on it.

Where Drizzle fits

Drizzle is the other ORM that has gained real momentum in TypeScript, and understanding it starts with recognizing that Prisma and Drizzle rest on genuinely different philosophies. Prisma tries to keep developers away from database complexity as much as it can. Drizzle takes the opposite position: keep developers close to SQL and simply add TypeScript's safety on top. Both chase the same destination, a better experience working with your database, by very different roads. Drizzle is a lightweight TypeScript ORM for PostgreSQL, MySQL, and SQLite, focused on type safety, performance, staying near SQL, and keeping its architecture slim. You still write TypeScript that flows through Drizzle into a query and on to the database.

TypeScript code
        |
        vDrizzle ORM        |
        vSQL query        |
        vDatabase

Placing the two next to each other makes the philosophical gap obvious. Retrieving every user in Prisma takes the form below, and the SQL it emits underneath rarely crosses your mind.

const user = await prisma.user.findMany();

Drizzle asks for something that reads much closer to actual SQL.

const users = await db
.select()
.from(usersTable);

You can practically read the database-level behavior straight off the code. Schemas differ too. Prisma holds them in a dedicated schema.prisma file, whereas Drizzle declares the schema in plain TypeScript, first importing the column builders it needs,

import {
 pgTable,
 serial,
 varchar
} from "drizzle-orm/pg-core";

and declaring the table with typed columns.

export const users = pgTable("users", { id: serial("id").primaryKey(), name: varchar("name"), email: varchar("email")});

Your database structure therefore lives right inside the TypeScript codebase, and there is no additional schema dialect to pick up. Everyday operations read like SQL wearing TypeScript clothing: an insert is an insert call carrying values, reading everything is a select from the table, and filtering appends a where clause built from an equality helper. People gravitate to Drizzle for a few reasons. It is genuinely lightweight, with little abstraction in the way. Because it stays near SQL, your SQL skills keep sharpening rather than fading behind an abstraction. Its generated queries tend to be predictable, since less machinery sits between your intent and the output. And it was designed with modern runtimes in mind, including edge and serverless environments.

The flip side is that if you have never really learned SQL, Drizzle can feel more confusing at first than Prisma, which is generally the gentler on-ramp for newcomers. Using Drizzle well genuinely requires understanding joins, relations, indexes, and how queries behave, with no hiding from any of it. And while its ecosystem, documentation, and community are solid, they remain smaller than Prisma's more mature surroundings. The cleanest way to frame the contrast is this: with Prisma you treat the database as a collection of objects and the emphasis is developer experience, while with Drizzle you treat the database more like SQL itself and the emphasis shifts toward performance and control.

The three approaches next to each other

With all three in view, their architectures stack up as a spectrum of how many layers sit between application and database.

Raw SQL:      Application → SQL Query → Database
Prisma:       Application → Prisma Client → SQL → Database
Drizzle:      Application → Drizzle → SQL → Database

Consider one simple operation, finding a user by email, against a basic users table. In raw SQL you send a parameterized query directly.

const result = await pool.query(
`
SELECT *
FROM users
WHERE email=$1
`,
["rahul@test.com"]
);

In Prisma you call a unique lookup,

const user =
await prisma.user.findUnique({

passing the email in a where clause.

where:{
 <email:"rahul@test.com>"
}});

And in Drizzle you build a select constrained by an equality on the email column.

const user =
await db
.select()
.from(users)
.where(
 eq(
 users.email,
 "rahul@test.com"
 )
);

All three return the same data in the end. What differs is how much control you want, how much abstraction you are comfortable with, and how much SQL you already know. A common beginner mistake is fixating on which one is fastest, when in real projects speed is rarely the deciding question. The better questions are what the team is already comfortable with, how complex the project genuinely is, how strong everyone's database knowledge is, and how complicated the queries are likely to become. A simple SaaS app is usually fine on Prisma alone, while a database-heavy application is where Drizzle, or raw SQL in places, starts to earn its keep.

To summarize the trade-offs in plain terms: the learning curve is hardest for raw SQL, easiest for Prisma, and moderate for Drizzle. Control over SQL is highest with raw SQL, high with Drizzle, and lowest with Prisma. Type safety is weak with raw SQL but excellent with both Prisma and Drizzle. Raw performance is excellent for raw SQL and Drizzle and good for Prisma. Developer experience is strongest with Prisma, good with Drizzle, and middling with raw SQL. Complex queries favor raw SQL and Drizzle, with Prisma in the middle. And on beginner-friendliness, Prisma leads, Drizzle sits in the middle, and raw SQL trails.

So what should you actually pick

Should an ORM always be your default? Honestly, no. Like any other tool, an ORM is right for some jobs and wrong for others, and nothing fits every project. Raw SQL earns its place when you need maximum control, when the application is genuinely database-heavy, when queries get complex, or when performance simply cannot slip. Analytics platforms, reporting systems, and financial applications fit the bill, as does anything that pushes serious volumes of data around. A report answering something like how much each customer has purchased over five years, along with their average order value, is naturally easier to express directly in SQL, selecting the aggregates,

SELECT
customers.name,
COUNT(orders.id),
AVG(orders.amount)

and joining and grouping to produce them.

FROM customersJOIN ordersON customers.id = orders.customer_idGROUP BY customers.name;

For work like that, nothing really replaces what SQL can express. The trouble is that relying on hand-written SQL for everything in a big codebase turns unwieldy quickly, leaving you with hundreds of query files spread around and type safety you must police yourself. A priority-ordered view of Node.js API performance shows how often the database, not the framework, is where the real wins and losses live.

Prisma tends to be the better call when you are building a TypeScript backend, doing mostly standard create-read-update-delete work, valuing development speed, and coordinating several developers in one codebase. That describes SaaS products, admin dashboards, storefront APIs, and content management systems, where the daily bread is creating a user, updating a profile, placing an order, or pulling dashboard data. Prisma genuinely lifts productivity there, turning a raw lookup into a clean, readable call any teammate can parse at a glance. Drizzle earns its spot when you work in TypeScript, already know SQL reasonably well, care about performance and control, and want something lightweight rather than heavy. It appeals to developers who want an ORM's safety net without feeling pulled too far from the database.

In practice, many real projects do not commit to a single approach. A startup might reasonably begin with a straight path from Express through Prisma to PostgreSQL.

Express → Prisma → PostgreSQL

As a first move that is genuinely sensible. Later, when the product expands and heavier analytics or reporting needs accumulate, teams often add raw SQL to particular corners instead of ripping Prisma out, arriving at an application that reaches PostgreSQL through both Prisma and hand-written queries.

Application
├── Prisma
└── Raw SQL Queries
        ↓
   PostgreSQL

Using one approach everywhere is not a rule you must follow, and most real codebases end up mixing them. Startups often lean on TypeScript, Prisma, and PostgreSQL purely for speed. Newer TypeScript projects increasingly reach for Drizzle and its lightweight, SQL-friendly style. And larger systems frequently combine an ORM with raw SQL and dedicated database optimization, simply because different parts of a big system run into different problems.

Mistakes worth avoiding

A few missteps recur. The first is treating the ORM as if it were the database. Prisma is not a database and neither is Drizzle; they are tools for reaching one, whether PostgreSQL, MySQL, or MongoDB underneath. The second is bypassing SQL altogether and going directly to Prisma. That feels productive at first and usually comes back to bite you, once slow queries stop being explicable, joins seem needlessly baffling, and tuning becomes genuinely hard for lack of any foundation beneath the abstraction. An ORM was never meant to replace SQL; it sits on top of it. The third is chasing raw performance numbers above all else. Developer productivity matters just as much in the real world, and shaving five milliseconds off a query while making development three months slower is rarely the smarter trade.

A learning path that pays off

Starting from zero today, a sensible order is to begin with SQL fundamentals, including tables, primary keys, foreign keys, joins, indexes, and transactions, using something like PostgreSQL. Then spend real time connecting directly with a driver such as Node's pg package, so you understand how an application talks to a database with nothing hidden in between. From there move into Prisma and get comfortable with schemas, migrations, relations, and its type safety. Finally, give Drizzle a proper try so you feel what an SQL-first query builder is like, along with the performance trade-offs it brings.

Where that leaves you

The purpose of an ORM was never to replace SQL. It exists to make the developer experience better, and nothing grander than that. A genuinely strong backend developer is not defined by knowing Prisma or Drizzle well. It is someone who understands how a database actually works, can write SQL when it is called for, knows what an ORM is simplifying on their behalf, and, just as importantly, knows when reaching for an ORM makes sense and when hand-written SQL is the smarter move. Once that clarity arrives, choosing among these tools stops being guesswork. You start deciding from what the project genuinely needs rather than from whatever happens to be trending this month.