πŸ“š Learning Hub
Β· 2 min read

Should You Use an ORM? An Honest Decision Guide


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: What is PostgreSQL Β· SQL Interview Questions