ORM vs Raw SQL: Trade-offs and When to Switch
Every backend developer eventually faces the ORM vs raw SQL debate, usually after hitting a query that the ORM generates badly or after spending an afternoon debugging why SQLAlchemy is doing seven round trips instead of one. Both approaches have legitimate uses, and the pragmatic answer is almost always “use both” — but knowing when to switch is the skill worth developing. This post covers the real trade-offs with concrete examples in Python (SQLAlchemy) and JavaScript/TypeScript (Prisma).
What an ORM Does
An ORM (Object-Relational Mapper) lets you interact with your database using the language’s native objects instead of SQL strings. It handles connection management, query generation, and (in most ORMs) migrations.
SQLAlchemy (Python)
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from models import User, Order
engine = create_engine("postgresql://localhost/mydb")
with Session(engine) as session:
users = session.scalars(
select(User)
.where(User.created_at > "2024-01-01")
.order_by(User.name)
.limit(50)
).all()
Generated SQL (roughly):
SELECT users.id, users.name, users.email, users.created_at
FROM users
WHERE users.created_at > '2024-01-01'
ORDER BY users.name
LIMIT 50;
Prisma (TypeScript)
const users = await prisma.user.findMany({
where: { createdAt: { gt: new Date("2024-01-01") } },
orderBy: { name: "asc" },
take: 50,
});
Both produce the same SQL from type-safe, autocompleted code.
Where ORMs Win
Boilerplate elimination
CRUD operations that would each require a parameterized SQL string become one-liners:
# Create
user = User(name="Alice", email="alice@example.com")
session.add(user)
session.commit()
# Update
user.name = "Alice Smith"
session.commit()
# Delete
session.delete(user)
session.commit()
Parameterization is automatic
ORMs handle query parameterization, so you never accidentally write:
# This is SQL injection waiting to happen
query = f"SELECT * FROM users WHERE name = '{user_input}'"
With an ORM, user input is always passed as a parameter, not interpolated into the query string.
Migrations and schema management
Tools like Alembic (SQLAlchemy) and Prisma Migrate generate migration files from your model definitions:
# Prisma: generate migration after changing schema.prisma
$ npx prisma migrate dev --name add_role_to_users
# Alembic: generate migration after changing models.py
$ alembic revision --autogenerate -m "add role to users"
Relationship loading
ORMs let you express relationships declaratively and handle join logic for you:
# SQLAlchemy: load user with their orders in one query
user = session.scalars(
select(User)
.options(selectinload(User.orders))
.where(User.id == 42)
).one()
for order in user.orders:
print(order.total)
Where ORMs Struggle
Complex aggregations
Anything beyond simple GROUP BY usually produces awkward ORM code or forces you into raw expressions anyway:
# ORM way — verbose and hard to read
from sqlalchemy import func
result = session.execute(
select(
User.id,
func.count(Order.id).label("order_count"),
func.sum(Order.total).label("lifetime_value"),
)
.join(Order, Order.user_id == User.id)
.where(User.created_at > "2024-01-01")
.group_by(User.id)
.having(func.sum(Order.total) > 1000)
.order_by(func.sum(Order.total).desc())
).all()
Raw SQL version:
SELECT
u.id,
COUNT(o.id) AS order_count,
SUM(o.total) AS lifetime_value
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.created_at > '2024-01-01'
GROUP BY u.id
HAVING SUM(o.total) > 1000
ORDER BY SUM(o.total) DESC;
The SQL is shorter, clearer, and easier to paste into a query analyzer.
The N+1 query problem
The classic ORM footgun: loading a list of objects and then accessing a relationship on each one triggers a separate query per row.
# This fires 1 + N queries (one for users, one per user for orders)
users = session.scalars(select(User)).all()
for user in users:
print(len(user.orders)) # lazy load fires here
Fix it with eager loading:
users = session.scalars(
select(User).options(selectinload(User.orders))
).all()
# Now it's 2 queries: one for users, one for all orders
But knowing when this will happen requires understanding what the ORM does under the hood — which somewhat defeats the abstraction.
Generated SQL quality
ORMs sometimes produce queries that are technically correct but inefficient. The only way to know is to log and inspect the SQL:
# SQLAlchemy: enable query logging
import logging
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
// Prisma: log all queries
const prisma = new PrismaClient({ log: ["query"] });
Always check the generated SQL for performance-critical paths.
Dropping Down to Raw SQL
Both SQLAlchemy and Prisma support raw queries when you need them.
SQLAlchemy raw SQL
from sqlalchemy import text
result = session.execute(
text("""
SELECT
u.id,
u.name,
COUNT(o.id) FILTER (WHERE o.status = 'completed') AS completed_orders
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > :since
GROUP BY u.id, u.name
ORDER BY completed_orders DESC
LIMIT :limit
"""),
{"since": "2024-01-01", "limit": 20},
).mappings().all()
Parameters are still bound properly — no injection risk.
Prisma raw SQL
const results = await prisma.$queryRaw<UserStats[]>`
SELECT
u.id,
u.name,
COUNT(o.id) FILTER (WHERE o.status = 'completed') AS completed_orders
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > ${new Date("2024-01-01")}
GROUP BY u.id, u.name
ORDER BY completed_orders DESC
LIMIT ${20}
`;
The tagged template literal automatically parameterizes interpolated values.
When to Use What
| Situation | Recommendation |
|---|---|
| Standard CRUD operations | ORM |
| Simple filtering, sorting, pagination | ORM |
| Migrations and schema management | ORM tooling |
| Complex aggregations, window functions | Raw SQL |
| Reporting queries | Raw SQL |
| N+1 problem you can’t easily fix | Raw SQL with JOIN |
| Query needs EXPLAIN tuning | Raw SQL |
| Full-text search, JSON operations | Raw SQL (or ORM expressions) |
A practical middle ground: use the ORM for all CRUD and simple reads, but maintain a queries/ directory of .sql files for complex reports and analytics. Load them with text() or $queryRaw. This keeps the benefits of ORM (migrations, connection pooling, parameterization) while letting complex queries be written and tested as SQL.
Conclusion
ORMs are not an abstraction over SQL — they’re a productivity layer on top of it. They shine for CRUD, schema management, and straightforward reads. They become a liability when query complexity grows, when generated SQL performs poorly, or when you need database-specific features. The best codebases use ORMs as the default and drop to raw SQL when the problem calls for it, rather than committing fully to one side of the debate.