Why I Ditched My ORM (And When I Still Keep One Around)
I once spent three days debugging a performance regression. The culprit? A single, seemingly innocuous SELECT COUNT(*) generated by my ORM that was hammering the database under load. We shipped that fix on a Friday afternoon, and I didn’t sleep well until Monday. That’s when I started questioning my ORM addiction.
This isn't about abstract theory. In 2026, we're drowning in data. Applications are expected to be faster, more responsive, and cheaper to run than ever. When your ORM becomes a black box that silently adds latency and complexity, it’s not just an annoyance; it’s actively costing you money and user trust. I’ve seen teams spend weeks chasing down performance issues that boil down to an ORM’s inefficient query generation. It’s time to get honest about what these tools are doing for us, and what they’re costing us.
Three Things I Learned Shipping This in Production
1. ORMs Lie About Simplicity
My first real taste of ORM pain came with a Python application using SQLAlchemy 1.4.x. We were building a user management system with complex, multi-table relationships. Initially, the ORM felt like magic. Defining models and querying them with Python objects seemed so much cleaner than writing raw SQL. Then came the reporting requirements.
We needed to generate a report showing users, their last login, and the number of support tickets they’d opened. A few User.query.join(Ticket).group_by(User.id).count() calls later, and we had a report. It worked. Until it didn’t. Under load, the generated SQL was a mess. It involved subqueries I couldn’t easily understand and joins that weren’t optimized for the specific aggregation. The ORM, in its attempt to abstract away SQL, had generated a query that was less performant than what a human could write. We ended up writing raw SQL for that specific reporting endpoint, bypassing the ORM entirely. The query time dropped from 15 seconds to under 1 second. The lesson: ORMs are great for CRUD, but they often stumble when you need complex aggregations or highly tuned queries.
SQLAlchemy ORM approach (initial, problematic)
from app.models import User, Ticket
from app import dbdef get_user_ticket_counts_orm():
users_with_counts = db.session.query(
User.id,
User.username,
func.count(Ticket.id).label('ticket_count')
).outerjoin(Ticket, User.id == Ticket.user_id).group_by(User.id).all()
return users_with_counts
Raw SQL approach (after optimization)
def get_user_ticket_counts_sql():
sql = """
SELECT
u.id,
u.username,
COUNT(t.id) AS ticket_count
FROM
users u
LEFT JOIN
tickets t ON u.id = t.user_id
GROUP BY
u.id;
"""
result = db.session.execute(sql)
return result.fetchall()
2. The "N+1" Problem is a Feature, Not a Bug (When You Don't Watch It)
This is the classic. You fetch a list of parent objects, and then for each parent, you fetch its children. If you have 10 parents and each has 10 children, that’s 1 parent query + 10 child queries. With an ORM like Hibernate (Java, version 5.6.x) or Entity Framework (.NET, version 6.0), this is incredibly easy to do accidentally.
We ran into this at a FinTech startup. We were displaying a list of customer accounts, and for each account, we needed to show their most recent transaction. The initial implementation looked something like this (conceptually, in Java with Hibernate):
// Hibernate ORM approach (initial, problematic)
List<Account> accounts = accountRepository.findAll(); // Query 1
for (Account account : accounts) {
Transaction latestTransaction = transactionRepository.findLatestForAccount(account.getId()); // Query 2, 3, 4... N
account.setLatestTransaction(latestTransaction);
}
This worked fine with 10 accounts. But when we hit 1000 accounts, the database started screaming. We saw thousands of SELECT statements in our logs. The ORM’s lazy loading, while convenient for individual object retrieval, became a performance killer in bulk operations. We fixed it by changing the fetching strategy to eagerly load the transactions or, more commonly, by writing a single, optimized SQL query that joined accounts and transactions and selected the latest one per account using window functions. The fix involved rewriting the data fetching logic to be aware of the relationships and fetching data in bulk. The ORM was capable of doing this efficiently, but it required explicit configuration (like JOIN FETCH in Hibernate or explicit Include in EF Core) or, more often, abandoning the ORM’s collection fetching for a custom query.
3. Schema Migrations Become a Minefield
Managing database schema changes is hard enough. Adding an ORM to the mix, especially with tools like Django's ORM (Python, version 4.2.x) or Ruby on Rails' ActiveRecord (Ruby, version 7.0.x), can introduce a new layer of complexity. When you rely heavily on the ORM to define your schema, you often find yourself writing migrations that are tightly coupled to the ORM's model definitions.
I remember a situation where we needed to rename a column. In Rails, this is usually straightforward: change_column :users, :email_address, :email. The ORM handles the SQL. However, we had a complex data import script that directly accessed the database tables using raw SQL, bypassing ActiveRecord entirely. When we ran the migration, the import script broke because it was still looking for email_address. The ORM only updated its own internal representation and the SQL it generated, not the expectations of other systems interacting with the database. This forced us to coordinate schema changes across the ORM and any other direct database access methods. It was a painful lesson in the implicit assumptions ORMs make about how your database is accessed. We ended up writing a separate raw SQL migration to handle renaming the column that the import script used, then ran the ActiveRecord migration. It was clunky and error-prone.
What I Would Do Differently If I Started Today
If I were building a new application from scratch in 2026, I’d start with a micro-ORM or a SQL builder library, and I'd be very deliberate about where I introduced a full ORM. For most applications, the simple CRUD operations that full ORMs excel at can be handled efficiently with libraries like Dapper (.NET, version 2.0.x), jOOQ (Java, version 3.17.x), or even just well-structured Python functions that execute raw SQL. I’d use these tools for most of my database interactions, only reaching for a full-blown ORM like Hibernate or SQLAlchemy for specific, well-defined domains where object-relational mapping provides a clear and significant benefit, and where I can meticulously control the query generation. My default would be to write SQL, and only abstract it when the gains clearly outweigh the costs.
What This Looks Like For Your Team
First, audit your database query logs. Seriously. If you’re using an ORM, look for the slow queries, the repetitive queries, and the queries that seem overly complex. Tools like pg_stat_statements for PostgreSQL or Cloud SQL Insights for Google Cloud can be invaluable. Second, identify your critical data access paths. Are these simple lookups, or are they complex reporting queries, aggregations, or bulk updates? For the latter, consider whether your ORM is truly helping or hindering. Third, experiment with SQL builders or micro-ORMs for new features. Start small. Build a new API endpoint using a library like sqlx (Rust, version 0.7.x) or knex.js (Node.js, version 0.20.x) and compare the performance and development effort to what you’d get with your existing ORM. You might be surprised at how much control and performance you gain.
I write about engineering decisions and production systems at devwithzach.com — drop me a line if any of this rings true.
John from California
just requested a quote
2 minutes ago