Why I Stopped Using ORMs: 3 Years Using, 2 Years Suffering
Invoice query that should be 50ms became 8 seconds because of Prisma N+1. ORMs make you productive early, but lock you in as scale grows. 3 years of Prisma + TypeORM experience.
0xNN · · 10 min read
I once built a "list unpaid invoices this month" feature. Simple query. Used Prisma. The code was clean:
const invoices = await prisma.invoice.findMany({
where: {
status: "unpaid",
createdAt: { gte: startOfMonth, lt: endOfMonth }
},
include: { customer: true, items: true }
})
Worked. 200ms. Fine. But in production, with 50,000 invoices, the query climbed to 8 seconds. I inspected - Prisma fetched all rows first, then filtered in the application layer for include. Classic N+1 problem.
I tried optimizing with raw query in Prisma. 50ms. Completely different. That's when I realized: ORM made me fast at writing code, but slow in production. I stopped using ORMs after that.
This isn't an "ORMs are bad" article. ORM is a tool. But after 3 years of using them and 2 years of suffering the consequences, I have a strong opinion: ORMs make you productive early, but lock you in as scale grows. This is my experience.
---
What Is an ORM, Really?
ORM = Object-Relational Mapper. A tool that maps database tables to objects in your code. You don't write SQL, you write code. Example with Prisma (TypeScript):
// ORM way
const user = await prisma.user.findUnique({
where: { id: 1 },
include: { posts: true }
})
-- SQL way
SELECT u.*, p.*
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
WHERE u.id = 1
Same result. Difference: ORM "speaks" like a developer (objects, methods, autocomplete). SQL "speaks" directly to the database.
ORM philosophy: abstraction. You don't need to understand SQL, just objects. But every abstraction has a cost.
---
Why ORMs Make You Fast Initially
1. Autocomplete + type safety.
You type prisma.user.find and the IDE suggests completions. No need to remember column names. Schema change? Type error shows up, not a runtime error in production. This genuinely boosts productivity 2-3x in early development.
2. Automatic migrations.
Change schema.prisma, run prisma migrate dev, database updates. No need to write SQL migrations manually. For MVPs, this is very fast.
3. Cross-database compatibility.
Write code once, runs on Postgres, MySQL, SQLite. Switch DB? Change datasource in config. (Spoiler: I never needed this in real production.)
4. Model relationships are clear.
user.posts - easy to read. SQL JOINs require thinking. For beginners, ORMs lower the barrier.
Those are real benefits. I don't dispute that. But all of it has a dark side that only appears as scale grows.
---
The Dark Side the Landing Page Doesn't Mention
1. N+1 problem - the ORM's main enemy.
The classic example:
// Fetch 100 users
const users = await prisma.user.findMany()
// Loop, fetch posts for each user
for (const u of users) {
const posts = await prisma.post.findMany({ where: { userId: u.id } })
// ...
}
The code looks innocent. But execution: 1 query for users + 100 queries for posts = 101 queries. Database overloaded.
SQL way: 1 query with JOIN. Done. ORMs can be worked around with include or eager loading, but still generate SQL that's sometimes not optimal.
2. You don't understand SQL, and that's dangerous.
ORMs teach you to "think in objects," not "think in sets." SQL is set-based thinking - "fetch all rows matching condition X, group by Y, aggregate Z." Different mental model.
When performance issues appear, you can't debug because you don't understand the SQL the ORM generates. You can't run EXPLAIN ANALYZE. You can't optimize indexes. You're stuck with "my code is right, why is it slow?"
3. Generated SQL is often suboptimal.
ORMs must handle all cases, so generated SQL is often over-generalized. Unnecessary subqueries. JOINs that should be LEFT but ORM uses INNER by default. You don't have fine control.
Example: Prisma's include often generates queries with multiple LEFT JOINs that could be replaced with EXISTS subqueries. 10x faster. But you can't express EXISTS with Prisma's API. You have to bypass to raw query.
4. Abstraction leaks at scale.
ORMs assume you work at row level: fetch 1 user, update 1 user. But in production, you need:
-- Bulk update 100,000 rows
UPDATE users SET status = 'inactive' WHERE last_login = $1 AND i.created_at < $2
GROUP BY i.id, c.name
ORMs won't generate this. They generate multiple queries or unnecessary subqueries.
3. Migrations become more deliberate.
Every schema change must be written in SQL manually. You think twice before adding a column, changing a type, etc. Migrations become more stable.
4. Type safety without ORM magic.
Kysely provides type safety in the query builder, without an abstraction layer. You write SQL, the IDE gives type errors if columns are wrong. Best of both worlds.
---
Quick Comparison
| | ORM (Prisma, TypeORM) | Raw SQL / Query Builder |
|---|---|---|
| Speed to write (early dev) | Fast (autocomplete, migrations) | Slow (manual SQL) |
| Speed at scale | Often slow (N+1, suboptimal query) | Optimal (you control) |
| Learning curve | Low | Medium (requires SQL understanding) |
| Debugging | Hard (ORM-generated query mystery) | Easy (you know the SQL) |
| Type safety | Yes (generally) | Yes (if using kysely/sqlx) |
| Migrations | Automatic, often permissive | Manual, more deliberate |
| Best for | MVP, CRUD apps | Production, large scale, complex queries |
---
Common Misconceptions
"ORMs make you more productive always." - No. Early on, yes. At scale, you can be slower debugging ORM-generated suboptimal queries.
"SQL is hard." - NO. SQL is set-based thinking, different mental model from OOP. Once you understand JOIN, GROUP BY, subquery, you can write queries that ORMs can't express.
"ORMs are safer from SQL injection." - Modern query builders are parameterized too. SQL injection appears when you string-concat, not because SQL itself is insecure.
"Using ORM = no need to understand SQL." - Fatal. You still need SQL for debugging, optimizing, and architecture at scale. ORM is a tool, not a knowledge replacement.
---
An Honest Closing
ORMs aren't the enemy. I still use them for internal prototypes, one-off scripts, MVPs. But for production code at scale, I moved to pure SQL (or a light query builder like kysely). Because: production needs control, performance, and debuggability that ORMs struggle to provide.
The philosophy I learned: abstraction is useful when you understand what's being abstracted. You can't effectively use an ORM if you don't understand the SQL it abstracts. You can only debug an ORM if you read its generated SQL. You can only optimize if you understand execution plans.
Want to use an ORM? Fine. But learn SQL too. Read the queries your ORM generates. Learn EXPLAIN ANALYZE. Once you understand what happens under the hood, you can choose: when to use ORM, when to fall back to raw SQL. That's what makes you a senior dev.
If you only rely on ORM without understanding SQL, you'll get stuck as scale grows. Like me, 8-second query that should be 50ms.