The Great ORM Debate
Few topics generate more heat than ORMs versus raw SQL. ORM advocates point to productivity and type safety. Raw SQL advocates point to performance and expressiveness. The answer, as usual, is "it depends" — but let's get specific about when each approach wins.
Where ORMs Shine
ORMs are excellent for CRUD operations. Creating a user, fetching an order by ID, updating a status field — these are simple operations that ORMs handle cleanly:
# SQLAlchemy (Python)
user = User(name="Alice", email="alice@example.com")
session.add(user)
session.commit()
order = session.get(Order, order_id)
order.status = "shipped"
session.commit()
# Prisma (TypeScript)
const user = await prisma.user.create({
data: { name: "Alice", email: "alice@example.com" }
})
const order = await prisma.order.update({
where: { id: orderId },
data: { status: "shipped" }
})
Beyond basic CRUD, ORMs provide:
- Migration management — schema changes tracked as code, reversible, version-controlled
- Type safety — Prisma and SQLAlchemy 2.0 give you compile-time checks that catch typos in column names and type mismatches
- Relationship loading — eager loading, lazy loading, and select-in loading strategies with a single configuration change
- Connection management — pooling, retry logic, and transaction scoping handled for you
Where ORMs Struggle
Complex queries expose ORM limitations fast. The moment you need window functions, CTEs, lateral joins, or database-specific features, you're either fighting the ORM's query builder or dropping down to raw SQL anyway.
-- This query is natural in SQL but painful in most ORMs:
WITH monthly_revenue AS (
SELECT
customer_id,
date_trunc('month', ordered_at) AS month,
sum(total) AS revenue,
lag(sum(total)) OVER (
PARTITION BY customer_id ORDER BY date_trunc('month', ordered_at)
) AS prev_month_revenue
FROM orders
WHERE ordered_at > now() - interval '12 months'
GROUP BY customer_id, date_trunc('month', ordered_at)
)
SELECT
c.name,
mr.month,
mr.revenue,
mr.prev_month_revenue,
round((mr.revenue - mr.prev_month_revenue) / mr.prev_month_revenue * 100, 1) AS growth_pct
FROM monthly_revenue mr
JOIN customers c ON c.id = mr.customer_id
WHERE mr.prev_month_revenue > 0
ORDER BY growth_pct DESC;
Trying to express this with an ORM's query builder usually results in code that's harder to read than the raw SQL and may generate inefficient queries under the hood.
The N+1 Problem
ORMs make N+1 queries dangerously easy. This innocent-looking code generates 101 queries:
# Python/SQLAlchemy with lazy loading (default)
orders = session.query(Order).limit(100).all()
for order in orders:
print(order.customer.name) # Each .customer triggers a SELECT
The fix is eager loading, but you need to remember to apply it every time:
from sqlalchemy.orm import joinedload
orders = session.query(Order).options(joinedload(Order.customer)).limit(100).all()
Query Builders: The Middle Ground
Query builders like Knex.js, SQLAlchemy Core (not ORM), and Diesel give you programmatic query construction without the object-mapping layer:
# SQLAlchemy Core (not ORM)
from sqlalchemy import select, func
stmt = (
select(
orders.c.customer_id,
func.count().label('order_count'),
func.sum(orders.c.total).label('total_revenue')
)
.where(orders.c.created_at > '2026-01-01')
.group_by(orders.c.customer_id)
.having(func.count() > 5)
.order_by(func.sum(orders.c.total).desc())
)
results = connection.execute(stmt)
You get composability (build queries programmatically, add conditions dynamically) and SQL-level expressiveness without the object-mapping overhead. For complex reporting queries, this is often the sweet spot.
Performance Comparison
I benchmarked these approaches on a table with 1 million rows (PostgreSQL 16, Python 3.12):
- Simple SELECT by ID: ORM ~0.8ms, Query Builder ~0.5ms, Raw SQL ~0.4ms
- Join with 3 tables: ORM ~12ms, Query Builder ~4ms, Raw SQL ~3.5ms
- Complex aggregation with CTE: ORM (impossible without raw escape), Query Builder ~8ms, Raw SQL ~7ms
- Bulk INSERT 10,000 rows: ORM ~2,800ms, Raw executemany ~180ms
The gap widens dramatically for bulk operations. ORMs track dirty state for every object, which is expensive when you're inserting thousands of rows. For bulk operations, always drop down to raw SQL or use the ORM's bulk insert methods (which bypass the unit-of-work tracking).
My Recommendation
Use an ORM for your application's CRUD layer — models, migrations, and simple queries. Drop down to raw SQL or a query builder for complex reporting queries, bulk operations, and anything with window functions or CTEs. Most ORMs support raw queries, so you don't have to choose one or the other — use both in the same codebase.
The worst outcome is rewriting a perfectly good ORM-based application in raw SQL because of a single slow query. Fix the slow query; leave the rest alone.