After 25 Years Using ORMs, I Switched to Raw SQL
I spent 25 years building applications with ORMs—Django ORM, SQLAlchemy, Hibernate, Entity Framework. They made my life easier. I could write Python code instead of SQL, swap databases with minimal changes, and rely on the framework to handle relationships.
Then I hit a wall.
The application I was working on slowed down. Pages that loaded in 200ms took 3 seconds. Database CPU spiked to 100%. I had been using ORMs the “right way”—select_related, prefetch_related, avoid N+1 queries. But the ORM still generated inefficient SQL.
That’s when I decided to switch to raw SQL. Let me explain why this made sense for an experienced developer, and when you should consider the same.
The ORM Trap
OR”Ms excel at rapid development. When you’re building a prototype, an MVP, or a small application, they’re perfect. You write:
# Django ORMusers = User.objects.filter(department='Engineering')And it generates SQL. You don’t think about joins, indexes, or execution plans. The framework handles it.
But here’s what happens as your application grows:
Hidden Queries
I once debugged a “simple” ORM query that executed 47 separate database calls. The code looked straightforward:
# Looks innocentusers = User.objects.filter(department='Engineering')
for user in users: print(user.profile.bio) # Triggers a query per userThe ORM fetched users first, then lazy-loaded each profile separately. With 50 users, that’s 51 database round-trips. I fixed it with select_related, but the point remains: ORMs hide what’s actually happening.
The N+1 Problem is Everywhere
Even when you know about N+1 queries, ORMs make it easy to introduce new ones:
# Django with prefetch_relatedusers = User.objects.prefetch_related('posts__comments')
for user in users: for post in user.posts.all(): print(post.comments.count()) # Still triggers queries!The prefetch_related helped, but accessing comments.count() inside a loop still hit the database. Raw SQL would have made this obvious—you’d see the queries you’re writing.
Performance becomes unpredictable
I spent hours debugging why a certain page was slow. The ORM query looked fine. But when I printed the generated SQL, it was a 200-line monster with 15 joins, subqueries, and no clear optimization path.
Raw SQL doesn’t hide this. You write the query, you see the query, you know exactly what will execute.
Why Raw SQL Wins
After switching to raw SQL, I found three immediate benefits.
1. Performance
Raw SQL is 20-50% faster for complex queries. Not because SQL itself is magic—because you write exactly what you need.
Consider this example. You want users from Engineering department with their profile bios:
ORM version:
users = User.objects.filter(department='Engineering').select_related('profile')This generates two queries:
-- Query 1SELECT * FROM users WHERE department = 'Engineering';
-- Query 2 (after select_related)SELECT * FROM profiles WHERE user_id IN (1, 2, 3, ...);Raw SQL version:
SELECT u.id, u.name, u.email, p.bioFROM users uLEFT JOIN profiles p ON u.id = p.user_idWHERE u.department = 'Engineering';One query. One round-trip. The database optimizer has complete visibility into what you’re doing. The raw SQL version is 50% faster just from eliminating the second query.
2. Predictability
With raw SQL, I know exactly what will execute:
# psycopg2 exampleasync def get_engineering_users(conn): await conn.execute(""" SELECT u.id, u.name, u.email, p.bio FROM users u LEFT JOIN profiles p ON u.id = p.user_id WHERE u.department = $1 """, ('Engineering',))No lazy loading. No hidden relationship fetching. No surprises. I can copy this query into psql, run EXPLAIN ANALYZE, and see the exact execution plan.
3. Debugging becomes straightforward
When a query is slow, I don’t wonder what the ORM generated. I see the SQL directly. I can:
- Paste it into database console and test variations
- Run
EXPLAIN ANALYZEto see the execution plan - Try different indexes or join strategies
- Benchmark changes immediately
There’s no translation layer to debug. The query I write is the query that runs.
When ORMs Are Still Better
I’m not saying ORMs are bad. They excel at:
Rapid prototyping: When you’re building an MVP, ORMs let you move fast. You don’t want to think about SQL syntax while you’re figuring out requirements.
Simple CRUD: Basic create, read, update, delete operations are tedious in raw SQL. ORMs handle these with minimal boilerplate:
# ORM
# Raw SQL (with psycopg2)await conn.execute( "INSERT INTO users (name, email) VALUES ($1, $2)",)Team with junior developers: Not everyone on your team will know SQL well. ORMs provide a safer abstraction for developers who aren’t database experts.
Schema migrations: Django’s migration system is excellent. I still use it even when writing raw SQL queries.
How I Made the Switch
I didn’t rewrite everything overnight. That would have been a disaster. Here’s the phased approach that worked:
Phase 1: Hybrid Approach
I kept the ORM for simple queries and switched to raw SQL for complex ones. My rule of thumb: if a query has more than 2 joins or involves subqueries, write it in raw SQL.
# Simple CRUD - still using ORMuser = User.objects.get(id=user_id)
# Complex analytics - raw SQLasync def get_user_stats(conn, user_id): return await conn.fetchrow(""" WITH user_posts AS ( SELECT id, created_at FROM posts WHERE user_id = $1 ), comment_stats AS ( SELECT COUNT(*) as total_comments FROM comments WHERE post_id IN (SELECT id FROM user_posts) ) SELECT (SELECT COUNT(*) FROM user_posts) as post_count, cs.total_comments, (SELECT COUNT(DISTINCT commenter_id) FROM comments WHERE post_id IN (SELECT id FROM user_posts)) as unique_commenters FROM comment_stats cs """, user_id)This gave immediate performance wins on the slowest endpoints without a full rewrite.
Phase 2: Migrate the Critical Path
I identified the top 10 slow queries in the application using database logs:
-- Enable slow query logging in PostgreSQLALTER SYSTEM SET log_min_duration_statement = 100;-- Log queries taking >100msThen I rewrote each one in raw SQL and benchmarked the improvement. Most showed 30-50% performance gains. The N+1 queries that I thought I’d fixed with select_related? Still there, hidden in different code paths.
Phase 3: Gradual Migration
After proving the benefits, I migrated module by module. Each migration followed this pattern:
- Write raw SQL version alongside ORM code
- Add tests to verify both produce same results
- Benchmark performance
- Switch over
- Monitor in production for a week
I never removed the ORM. It’s still there for new features where performance isn’t critical.
Practical Tips for Switching
If you’re considering raw SQL, here’s what I learned:
Use Parameterized Queries
Never, ever interpolate strings:
# WRONG - SQL injection vulnerabilityquery = f"SELECT * FROM users WHERE id = {user_id}"
# CORRECT - parameterizedawait conn.execute("SELECT * FROM users WHERE id = $1", (user_id,))Modern database libraries like psycopg2, asyncpg, and aiomysql all support parameterized queries. Use them.
Build a Small Query Library
You’ll find yourself repeating patterns. Create helpers:
async def paginate(conn, query, params, page=1, per_page=20): offset = (page - 1) * per_page return await conn.fetch(f""" {query} LIMIT $1 OFFSET $2 """, *params, per_page, offset)Use Database EXPLAIN
Before deploying a query, run it through EXPLAIN ANALYZE:
EXPLAIN ANALYZESELECT u.id, u.name, p.bioFROM users uLEFT JOIN profiles p ON u.id = p.user_idWHERE u.department = 'Engineering';Look for:
- Seq Scans on large tables (might need an index)
- High cost numbers
- Unexpected nested loops
Keep the ORM for Migrations
I still use Django migrations to manage schema changes. Raw SQL is for queries, not schema evolution.
The Mental Model Shift
Switching to raw SQL required a mental shift. With ORMs, I thought in terms of objects and relationships. With raw SQL, I think in terms of sets and joins.
At first, this felt less intuitive. But over time, I developed a deeper understanding of what the database actually does. I started thinking about:
- Which indexes will be used
- How the query planner will optimize my joins
- Whether a CTE (Common Table Expression) would be clearer
- When to use a window function vs a subquery
This understanding made me a better developer, even when I do use ORMs.
When to Stick with ORMs
Raw SQL isn’t always the right answer. Stick with ORMs if:
- You’re building an MVP or prototype
- Your team lacks SQL expertise
- Performance isn’t critical yet
- You have simple data models with few relationships
- You need cross-database compatibility
OR”Ms are tools. Raw SQL is a tool. The best developers know when to use each.
What I’d Do Differently
Looking back, I should have made this switch earlier. The “complexity” of raw SQL is overstated. Writing a JOIN isn’t harder than understanding select_related vs prefetch_related. Parameterized queries are just as safe as ORM escaping.
The only real downside is losing some convenience. But when performance matters, that’s a trade worth making.
I’d also start with a hybrid approach from day one. Use ORMs for simple CRUD, write complex queries in raw SQL. Don’t wait until performance problems force your hand.
The Bottom Line
After 25 years with ORMs, I switched to raw SQL for the same reason I learned SQL in the first place: control. When you need predictable performance, when you need to optimize at the database level, when you need to understand exactly what your application is doing—raw SQL wins.
OR”Ms aren’t bad. They’re great for getting started. But eventually, you outgrow them. When that happens, don’t be afraid to write SQL directly. The database is where your data lives. You should speak its language when it matters.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments