The ORM debate never ends. Hereβs a framework for actually deciding.
The three options
Raw SQL: Write SQL directly. Full control, no abstraction.
SELECT u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id;
Query builder (Knex, Kysely): Programmatic SQL construction. Type-safe, composable.
db.selectFrom('users')
.leftJoin('orders', 'orders.user_id', 'users.id')
.select(['users.name', db.fn.count('orders.id').as('order_count')])
.groupBy('users.id');
ORM (Prisma, Drizzle, TypeORM): Objects map to tables. Highest abstraction.
const users = await prisma.user.findMany({
include: { orders: true },
});
Use an ORM when:
Your app is CRUD-heavy. If 80% of your queries are create, read, update, delete on single tables with simple relations, an ORM saves massive time.
You want type safety. Prisma and Drizzle generate types from your schema. Change a column name and TypeScript tells you every place that breaks.
Your team isnβt SQL-fluent. ORMs lower the barrier. A junior developer can write correct, safe database queries without knowing SQL joins.
You need migrations. ORMs handle schema migrations. prisma migrate dev is easier than writing ALTER TABLE statements by hand.
Use raw SQL when:
Performance is critical. ORMs generate SQL thatβs often good enough but rarely optimal. For high-traffic queries, hand-written SQL with proper indexes wins.
Complex queries. Window functions, recursive CTEs, lateral joins, complex aggregations β ORMs either canβt express these or make them harder than raw SQL.
Youβre a SQL expert. If you think in SQL, an ORM is a translation layer that slows you down.
Reporting and analytics. Dashboards with complex aggregations across many tables are easier in SQL.
Use a query builder when:
You want the middle ground. Type-safe, composable queries without the full ORM abstraction. You write SQL-like code but get TypeScript support.
Dynamic queries. Building queries conditionally (filters, sorting, pagination) is cleaner with a query builder than string concatenation.
let query = db.selectFrom('products').selectAll();
if (filters.category) query = query.where('category', '=', filters.category);
if (filters.minPrice) query = query.where('price', '>=', filters.minPrice);
The hybrid approach
Most production apps use a mix:
- ORM for CRUD operations (90% of your code)
- Raw SQL for complex queries (reports, analytics, performance-critical paths)
Prisma supports this:
// ORM for simple stuff
const user = await prisma.user.findUnique({ where: { id: 1 } });
// Raw SQL for complex stuff
const report = await prisma.$queryRaw`
SELECT date_trunc('month', created_at) as month, SUM(total) as revenue
FROM orders
WHERE created_at > NOW() - INTERVAL '1 year'
GROUP BY month
ORDER BY month;
`;
The decision flowchart
Is your app mostly CRUD?
βββ Yes β ORM (Prisma or Drizzle)
βββ No β
Do you need dynamic query composition?
βββ Yes β Query builder (Kysely)
βββ No β
Are you comfortable with SQL?
βββ Yes β Raw SQL with a thin wrapper
βββ No β ORM (it'll teach you SQL patterns over time)
My recommendation for 2026
Drizzle ORM is the sweet spot right now. Itβs an ORM that feels like a query builder β you get type safety and migrations but the syntax is close to SQL. Youβre never fighting the abstraction.
const result = await db
.select({ name: users.name, orderCount: count(orders.id) })
.from(users)
.leftJoin(orders, eq(orders.userId, users.id))
.groupBy(users.id);
Thatβs readable, type-safe, and you can see the SQL itβll generate. Best of both worlds.
Related resources
Related: What is PostgreSQL Β· SQL Interview Questions