Skip to content

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 ORM
users = 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 innocent
users = User.objects.filter(department='Engineering')
for user in users:
print(user.profile.bio) # Triggers a query per user

The 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_related
users = 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 1
SELECT * 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.bio
FROM users u
LEFT JOIN profiles p ON u.id = p.user_id
WHERE 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 example
async 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 ANALYZE to 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
user = User.objects.create(name="Alice", email="[email protected]")
# Raw SQL (with psycopg2)
await conn.execute(
"INSERT INTO users (name, email) VALUES ($1, $2)",
("Alice", "[email protected]")
)

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 ORM
user = User.objects.get(id=user_id)
# Complex analytics - raw SQL
async 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 PostgreSQL
ALTER SYSTEM SET log_min_duration_statement = 100;
-- Log queries taking >100ms

Then 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:

  1. Write raw SQL version alongside ORM code
  2. Add tests to verify both produce same results
  3. Benchmark performance
  4. Switch over
  5. 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 vulnerability
query = f"SELECT * FROM users WHERE id = {user_id}"
# CORRECT - parameterized
await 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 ANALYZE
SELECT u.id, u.name, p.bio
FROM users u
LEFT JOIN profiles p ON u.id = p.user_id
WHERE 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