Stop hiding from SQL. Academic research documents ORMs adding 10-100x overhead on complex queries - and I've watched teams spend months debugging the abstraction instead of the database. After 30+ years writing database code, I've used zero ORMs in production systems I'm proud of. Every ORM I've tried eventually became the problem.
Write SQL directly for performance-critical code. Learn your database's capabilities—they exceed what ORMs expose. SQL skill pays dividends.
The logic is sound on paper.
This take will be unpopular. Every modern framework pushes an ORM. Every bootcamp teaches ORM-first. "Don't write SQL, it's not portable!" The industry has decided that mapping objects to relations is the only civilized way to work with databases. I disagree. It's another example of technical debt that accumulates silently until it becomes the bottleneck.
What ORMs Promise
The pitch is compelling:
Write objects, not SQL. Define your models in your language of choice. The ORM generates the SQL. You never have to think about tables.
Database portability. Want to switch from PostgreSQL to MySQL? Just change the connection string. The ORM handles the differences.
Protection from SQL injection. The ORM handles parameterization. No more concatenating strings into queries.
Automatic migrations. Change your model, generate a migration. The database schema follows your code.
Relationships handled automatically. Define a foreign key in your model, and the ORM loads related objects for you. No manual joins.
If these worked as advertised, ORMs would be great. In my experience, they don't.
The N+1 Query Problem
Every ORM has lazy loading. Load an object, access a related collection, the ORM fetches it automatically. Convenient, until you do this in a loop:
users = User.all()
for user in users:
print(user.orders.count) # This fires a query for EACH userThat's 101 queries for 100 users. The ORM made it trivially easy to write code that performs terribly. You don't even realize you've done it until the database melts.
Yes, ORMs have eager loading to fix this. But using it requires understanding the underlying query patterns, which defeats the point of the abstraction. The ORM hides the database until it doesn't, and then both the ORM and the database require understanding.
Try It: The N+1 Query Problem
Run this to see the query explosion:
"""N+1 Query Demo: ORM lazy loading vs raw SQL"""
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
from sqlalchemy.orm import sessionmaker, relationship, declarative_base
import time
Base = declarative_base()
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
name = Column(String(50))
orders = relationship("Order", back_populates="user")
class Order(Base):
__tablename__ = 'orders'
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'))
user = relationship("User", back_populates="orders")
# Setup
engine = create_engine('sqlite:///:memory:', echo=True) # echo=True shows all queries
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
# Create 100 users with 5 orders each
for i in range(100):
u = User(name=f"User {i}")
session.add(u)
session.flush()
for j in range(5):
session.add(Order(user_id=u.id))
session.commit()
print("\n--- ORM LAZY LOADING (N+1 Problem) ---")
start = time.time()
users = session.query(User).all()
for user in users:
_ = len(user.orders) # Triggers separate query for EACH user
print(f"Time: {time.time()-start:.3f}s (101 queries!)")
print("\n--- RAW SQL (Single Query) ---")
start = time.time()
result = session.execute("""
SELECT u.id, 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
""")
for row in result:
pass
print(f"Time: {time.time()-start:.3f}s (1 query)")The ORM makes N+1 queries trivially easy to write. Raw SQL forces you to think in joins.
The Query Builder Trap
ORMs include query builders for "complex" queries. They promise you'll never need raw SQL. In practice:
# ORM query builder
results = (
session.query(User)
.join(Order)
.filter(Order.total > 100)
.filter(User.created > date.today() - timedelta(days=30))
.group_by(User.id)
.having(func.count(Order.id) > 5)
.order_by(desc(func.sum(Order.total)))
.limit(10)
)Compare to SQL:
SELECT u.* FROM users u
JOIN orders o ON o.user_id = u.id
WHERE o.total > 100
AND u.created > CURRENT_DATE - INTERVAL '30 days'
GROUP BY u.id
HAVING COUNT(o.id) > 5
ORDER BY SUM(o.total) DESC
LIMIT 10The SQL is shorter, clearer, and doesn't require knowing a query builder API. The ORM version requires learning a pseudo-SQL syntax that's different for every ORM, often less capable than actual SQL, and still maps to SQL anyway. Recent ScienceDirect research confirms that raw SQL proves superior for execution performance and system resource utilization.
And when the query builder can't express what's needed? Raw SQL becomes the answer. So SQL knowledge is still required. But now two syntaxes are mixed in the same codebase.
The Portability Myth
"ORMs make your code database-portable." How often do you actually switch databases?
In 30 years, I've migrated production databases exactly twice. Both times, the ORM was irrelevant because:
- The data migration was the hard part, not the queries
- Performance characteristics differed enough that we needed to rewrite queries anyway
- Database-specific features we used (full-text search, JSONB, etc.) weren't portable
Optimizing for a migration that might happen once per decade while accepting daily complexity overhead is a bad trade.
And if you do need portability - say, for a product that runs on customer databases - you probably need to test on all targets anyway, at which point writing database-specific SQL and testing it is more reliable than hoping the ORM handles edge cases correctly.
The Abstraction That Leaks
Every ORM is a leaky abstraction. The database model doesn't map cleanly to objects:
NULL handling. Databases have NULL. Objects have null/None/nil. These are not the same thing. NULL in SQL has three-valued logic. The ORM has to map this to your language's null, and the mapping is always awkward. This is part of what the industry calls "the object-relational impedance mismatch" - described as "the Vietnam of computer science."
Identity and equality. Is an object equal to another object with the same database ID? What if the fields differ because one was loaded before an update? ORM identity rules are confusing and bug-prone.
Transactions and object state. You update an object. Is it saved? Is the database updated? When does the transaction commit? What if the commit fails - is the object now in an invalid state? ORM transaction handling is a constant source of bugs.
Inheritance. Databases don't have inheritance. ORMs fake it with various strategies (single table, table per class, joined tables). Each has tradeoffs. Picking wrong creates performance problems that are hard to fix later.
The ORM tries to pretend the database is just an object store. It isn't. The mismatch creates friction that you pay for on every project. It's another form of the layer tax - abstraction that costs more than it saves.
What I Do Instead
I write SQL directly, with some practices that address the ORM's legitimate benefits:
Parameterized queries always. Never concatenate strings into SQL. Use query parameters. This handles SQL injection without needing an ORM.
# Bad
cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")
# Good
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
Query functions, not query strings. Wrap queries in functions with clear names. The function is the interface; the SQL is the implementation.
def get_active_users_with_recent_orders(min_order_count: int, days: int):
"""Return users with recent order activity."""
return db.execute("""
SELECT u.* FROM users u
WHERE u.id IN (
SELECT o.user_id FROM orders o
WHERE o.created > NOW() - INTERVAL '%s days'
GROUP BY o.user_id
HAVING COUNT(*) >= %s
)
""", (days, min_order_count))
Plain data, not magic objects. Query returns data. Data goes into a dataclass/struct/named tuple. No magic methods, no lazy loading, no surprise queries.
Migration scripts as SQL. Migrations are SQL files, version controlled, run in order. No ORM migration DSL. The migration does exactly what the SQL says.
Embrace database features. PostgreSQL has JSONB, full-text search, array types, window functions. Use them. They're more capable than anything you'll build in application code.
When ORMs Might Be Okay
I'm contrarian, not dogmatic. ORMs can be fine for:
CRUD-heavy applications. If you're mostly doing simple create/read/update/delete without complex queries, the ORM overhead is minimal.
Rapid prototyping. When you're figuring out what to build, ORM speed can matter more than ORM problems. Just be ready to rewrite.
Teams with no SQL experience. If your team genuinely doesn't know SQL, an ORM provides guardrails. But teach them SQL - it's a more valuable skill.
When the framework requires it. If you're using Django and fighting the ORM, you're probably losing. Use the ORM or use a different framework.
The Bottom Line
ORMs solve the wrong problem. They try to hide the database, but the database is essential to understand. They provide a pseudo-SQL syntax that's less capable than SQL. They create performance traps that require understanding both the ORM and the database to fix.
SQL is a skill worth learning. It's been stable for 40 years. It works on every database you'll encounter. It's more powerful than any ORM query builder. And when you write SQL directly, there's no magic hiding what your code actually does.
The high-volume systems I've worked on - ones handling billions of database operations - didn't use ORMs. The code was simpler, the performance was better, and the debugging was easier. Your mileage may vary.
"SQL is a skill worth learning. It's been stable for 40 years. It works on every database you'll encounter. It's more powerful than any ORM query builder."
Sources
- ScienceDirect: ORM vs Raw SQL Performance — Academic study comparing ORM query performance to direct SQL, documenting 10-100x overhead in complex queries
- Martin Fowler: ORM Hate — Industry analysis of why ORMs create friction, including the object-relational impedance mismatch problem
- Wikipedia: Object-Relational Impedance Mismatch — Comprehensive overview of the fundamental mismatch between object models and relational databases
