Draft / Scheduled Content
This article is a draft or scheduled for future publication. The content is subject to change.
Why ORMs are the Worst Anti-Pattern in Modern Backend Development
The Promise of Object-Relational Mapping
The pitch for Object-Relational Mappers (ORMs) like Hibernate, Entity Framework, Prisma, or ActiveRecord is incredibly seductive:
“Stop writing raw, messy SQL strings in your code! Treat your database tables as native programming objects. Let the ORM handle the queries, the joins, the schema migrations, and the database dialects automatically. Focus on your business logic, not database queries.”
It sounds like a dream. For junior developers or teams trying to build an MVP quickly, it feels like magic.
But as your application grows, this magic curdles into a nightmare.
You find your API endpoints taking seconds to respond because of hidden database calls. You write convoluted, unreadable OOP methods to achieve what should be a simple GROUP BY query. You struggle to optimize database indexes because you have no direct control over the SQL being executed.
ORMs are a classic example of a leaky abstraction. They try to hide the relational database paradigm behind an object-oriented curtain. Because these two models are fundamentally mismatched, the curtain is paper-thin, and the leaks will eventually drown your application.
The Object-Relational Impedance Mismatch
Why do ORMs fail? Because they try to bridge two completely different paradigms:
- Object-Oriented Programming (OOP) is based on identity, encapsulation, and graphs of references. Objects have behavior and state, and they reference other objects directly in memory.
- Relational Databases (SQL) are based on set theory, relations, and mathematical logic. They store data in flat tables and relate them via foreign keys. They do not have behavior, and they optimize for bulk data manipulation.
ORMs try to force relational tables into object graphs. To do this, they make a series of compromises that ruin both paradigms.
The Infamous N+1 Query Problem
The most common performance disaster in web development is the N+1 query problem. And ORMs make it incredibly easy to write—often by default.
Consider this Python code using a typical ORM (like SQLAlchemy or Django ORM):
# Fetch all active users
users = User.objects.filter(status="active")
# Print each user and their department name
for user in users:
print(f"{user.name}: {user.department.name}")
This code looks clean, readable, and perfectly safe.
But under the hood, the ORM executes:
- 1 query to fetch the users:
SELECT * FROM users WHERE status = 'active';(Returns $N$ users). - $N$ separate queries to fetch the department for each user:
SELECT * FROM departments WHERE id = [user.department_id];
If you have 100 active users, this code executes 101 database roundtrips.
On a local machine with 0ms database latency, this goes unnoticed. In production, where each network roundtrip to the database takes 2-5ms, your API response time balloons to half a second.
To fix this, you have to tell the ORM to “eagerly load” or “select-related” the departments:
users = User.objects.filter(status="active").select_related("department")
But now, the simplicity is gone. The developer must manually trace the object graph and predict exactly which relations will be accessed in the view layer. If a future developer adds user.department.manager.name to the print statement, they introduce another set of hidden queries, and the performance silent-killer returns.
In raw SQL, you simply write a join. The database optimizer handles it in a single pass:
SELECT u.name, d.name AS dept_name
FROM users u
INNER JOIN departments d ON u.department_id = d.id
WHERE u.status = 'active';
It is impossible to write an N+1 query in raw SQL because you have to be explicit about what data you are requesting.
The Black-Box SQL Generation
When you use an ORM, you lose control of the queries hitting your database.
If you want to fetch users who bought a product in the last 30 days but didn’t buy anything this week, you write a complex chain of ORM filter methods. The ORM translates this chain into SQL.
Often, the generated SQL is a monstrosity filled with nested subqueries, unnecessary joins, and weird alias names (SELECT t1.id AS t1_id, t2.name AS t2_name ...).
Your database query planner looks at this mess and fails to choose the correct index. The query does a full table scan.
When your DBA (Database Administrator) asks you why the database CPU is at 100%, you can’t show them the code. You have to debug the generated SQL, map it back to the ORM query, and figure out how to trick the ORM into generating the correct SQL structure.
You are spending your energy fighting the ORM’s translation engine rather than working with the database.
Coupling Domain Logic to the Schema
ORMs encourage you to use the same class for your database schema representation and your business logic.
Your User class has decorators defining database constraints (@Column, @PrimaryKey), serialization settings, and domain methods.
This violates the Single Responsibility Principle. If you want to change your database column type or rename a table, you risk breaking your business logic. Your domain model is held hostage by your database schema.
+---------------------------------------------------+
| ORM "User" Class |
| - Database Schema (columns, constraints, keys) |
| - Domain Business Logic (rules, state machines) |
| - API Serialization / Validation logic |
+---------------------------------------------------+
|
v (High Coupling!)
Database change breaks domain rules.
The Alternative: Pragmatic SQL
You don’t have to write raw strings in your code and concatenate values (which leads to SQL injection).
Modern backend development offers excellent alternatives:
1. Simple Query Builders (Knex.js, Kysely, Squirrel)
Query builders allow you to construct SQL programmatically without trying to map tables to objects. You retain full control over the query structure:
// Kysely (TypeScript query builder)
const users = await db
.selectFrom('users')
.innerJoin('departments', 'users.department_id', 'departments.id')
.select(['users.name as userName', 'departments.name as deptName'])
.where('users.status', '=', 'active')
.execute();
The result is a flat, predictable array of plain objects. No magic, no hidden queries.
2. SQL-First Libraries (sqlx, Slonik, Yesql)
These libraries let you write raw, optimized SQL in separate .sql files or strings, and map the results to typed data structures automatically.
// Go + sqlx
type UserDept struct {
UserName string `db:"user_name"`
DeptName string `db:"dept_name"`
}
var results []UserDept
err := db.Select(&results, `
SELECT u.name AS user_name, d.name AS dept_name
FROM users u
INNER JOIN departments d ON u.department_id = d.id
WHERE u.status = ?`, "active")
You get the full power of SQL (window functions, CTEs, complex aggregation) with type-safe outputs and zero overhead.
Know Your Database
A relational database is not a dumb storage bin. It is a highly optimized, powerful engine designed to process sets of data.
To build high-performance, maintainable backend systems, you must understand your database. You must know how indexes work, what a query execution plan looks like, and how to write efficient SQL.
ORMs encourage developers to remain database-illiterate. They promise that you can ignore the database. That promise is a lie.
Ditch the ORM. Write SQL. Take control of your data.
Related Content
Why MongoDB is Still the Wrong Choice for 99% of Projects
The 'schemaless' pitch of document databases promised database flexibility and rapid iteration. In reality, your data always has schema. Moving schema constraints to the application layer leads to data drift, write corruption, and slow queries. PostgreSQL is almost always the correct answer.
Software Architecture Is Mostly About Boundaries
A practical guide to drawing boundaries that survive contact with reality: APIs, modules, ownership, and the uncomfortable fact that most bugs are boundary bugs wearing a fake mustache.
Why Clean Architecture is a Maintainability Nightmare
Robert C. Martin's 'Clean Architecture' promises to decouple your business logic from external frameworks and databases. In practice, it leads to a sprawling wasteland of boilerplate, interface layers that pass data straight through, and excessive mapping functions. Here's why YAGNI should trump architecture dogmatism.