ORM vs Raw SQL Performance: Settling This Matter Once and for All
The overhead is not where you think it is. Allow me to present the evidence.
Good evening. I see you have an opinion on this matter.
Everyone does. The ORM-versus-raw-SQL debate is one of the few technical arguments that generates genuine heat — and very little light. "ORMs are slow" is stated with the confidence of a physical law. "ORMs generate the same SQL" is the rebuttal, delivered with equal certainty. Both are sometimes true. Neither is always true.
I have encountered this argument at every scale of production system I have attended to. The junior developer who has just discovered EXPLAIN ANALYZE and is now convinced that Django is the source of all suffering. The senior architect who insists on raw SQL everywhere and wonders why new hires keep introducing SQL injection vulnerabilities. The team lead who read a blog post titled "ORMs Considered Harmful" in 2014 and has organized their entire stack around avoiding them.
All of them are working from incomplete information.
I propose we resolve this with numbers rather than convictions. I have benchmarked nine common query patterns across ORM-generated and hand-written SQL, using Django, Rails, SQLAlchemy, and Prisma against PostgreSQL 16 with ten million rows. The results are instructive — not because one approach wins, but because the performance gap exists in places most developers do not expect, and is entirely absent from the places where they do.
What SQL does the ORM actually generate?
Let us begin by examining what the ORM produces. For a simple join with a filter, here is the Django ORM code and its generated SQL:
# Django ORM — a simple join with filter
orders = Order.objects.filter(
status='pending',
created_at__gte='2025-01-01'
).select_related('customer').values(
'id', 'total', 'customer__name'
) -- SQL generated by the Django ORM above:
SELECT "orders"."id", "orders"."total", "customers"."name"
FROM "orders"
INNER JOIN "customers" ON ("orders"."customer_id" = "customers"."id")
WHERE ("orders"."status" = 'pending'
AND "orders"."created_at" >= '2025-01-01') And here is the equivalent hand-written query:
-- Hand-written raw SQL for the same query:
SELECT o.id, o.total, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'pending'
AND o.created_at >= '2025-01-01' They are, for all practical purposes, identical. The ORM quoted the identifiers and used a fully-qualified join — both correct, both irrelevant to performance. PostgreSQL's query planner produces the same plan for both. The planner does not care whether your identifiers are quoted. It does not penalize you for writing "orders"."status" instead of o.status. It parses both into the same internal representation and proceeds identically.
This is worth emphasizing because I encounter the misconception with some regularity: the belief that ORM-generated SQL is somehow structurally inferior. That the extra quoting, the verbose table references, the occasional redundant parentheses — that these carry a performance cost. They do not. The query planner strips all of this away in the parsing phase. By the time it reaches plan selection, the ORM query and the hand-written query are indistinguishable.
For aggregations, the picture is the same:
# Django ORM — aggregation with annotation
from django.db.models import Count, Sum, Avg
Order.objects.filter(
created_at__gte='2025-01-01'
).values('region').annotate(
order_count=Count('id'),
total_revenue=Sum('total'),
avg_order=Avg('total')
).order_by('-total_revenue') -- What Django generates:
SELECT "orders"."region",
COUNT("orders"."id") AS "order_count",
SUM("orders"."total") AS "total_revenue",
AVG("orders"."total") AS "avg_order"
FROM "orders"
WHERE "orders"."created_at" >= '2025-01-01'
GROUP BY "orders"."region"
ORDER BY "total_revenue" DESC
-- Identical to what you would write by hand. The same pattern holds across frameworks. Here is SQLAlchemy 2.0 expressing the same join:
# SQLAlchemy 2.0 — the modern "select" style
from sqlalchemy import select
stmt = (
select(Order.id, Order.total, Customer.name)
.join(Customer)
.where(Order.status == 'pending')
.where(Order.created_at >= '2025-01-01')
)
results = session.execute(stmt).all()
# Generated SQL: identical to Django's output.
# SQLAlchemy 2.0's select() syntax is closer to SQL
# than Django's queryset chaining. Some find this
# more transparent. The SQL output is the same. And Prisma, from the JavaScript ecosystem:
// Prisma — the same join query
const orders = await prisma.order.findMany({
where: {
status: 'pending',
createdAt: { gte: new Date('2025-01-01') },
},
include: { customer: { select: { name: true } } },
});
// Generated SQL:
// SELECT orders.id, orders.total, customers.name
// FROM orders
// LEFT JOIN customers ON orders.customer_id = customers.id
// WHERE orders.status = 'pending'
// AND orders.created_at >= '2025-01-01'
// Note: Prisma uses LEFT JOIN here (even with required relations).
// Functionally identical for this query. Same plan. Four ORMs. Four languages. The generated SQL is, in every case, functionally identical to what you would write by hand. The performance difference on these queries is effectively zero — a fraction of a millisecond attributable to object creation on the application side, invisible at the database level.
This is the boring finding. It is also the correct one. And it is the one most people overlook because it does not confirm their priors.
Where does the ORM actually lose?
The ORM's performance problems are not in the SQL it generates. They are in the SQL it generates without being asked — or in the patterns it encourages that lead to bad query strategies. This is a critical distinction, and I would ask you to keep it in mind as we proceed.
The overhead is not "ORM translation cost." It is not the time spent converting Python objects to SQL strings. That cost is measured in microseconds and is genuinely irrelevant. The real overhead comes from three sources: query multiplication, pattern selection, and expressiveness ceilings.
The N+1 pattern
# Rails — the accidental N+1
Order.where(status: 'pending').each do |order|
puts order.customer.name
end
# Generated SQL:
# SELECT * FROM orders WHERE status = 'pending';
# SELECT * FROM customers WHERE id = 1;
# SELECT * FROM customers WHERE id = 2;
# ... one per order. This is the ORM's fault.
# Raw SQL never has this problem:
# SELECT o.*, c.name FROM orders o JOIN customers c ON ... This is not a flaw in the SQL the ORM generates — each individual query is perfectly fine. The flaw is that the ORM generates 101 queries where a single join would suffice — a classic N+1 problem. The overhead is not 5% or 10%. It is 5,000%. And it is entirely the ORM's doing — raw SQL with a JOIN never has this problem because you wrote the join explicitly.
Every major ORM provides eager loading to prevent this (select_related in Django, includes in Rails, joinedload in SQLAlchemy, include in Prisma). But you must know to use it. The default behavior is lazy loading — and lazy loading is the N+1 factory.
The fix is straightforward, and it is worth seeing:
# Rails — the same query, with eager loading
Order.where(status: 'pending').includes(:customer).each do |order|
puts order.customer.name
end
# Generated SQL:
# SELECT * FROM orders WHERE status = 'pending';
# SELECT * FROM customers WHERE id IN (1, 2, 3, ...);
# Two queries. Not 101. The word "includes" did all the work. One word — includes — reduces 101 queries to 2. The performance improvement is not incremental. It is categorical. And yet I encounter N+1 patterns in production systems with distressing regularity, often in code that has been running for years. The developer who wrote it never noticed because their development database had 50 rows. With 50 rows, 51 queries complete in 12ms. With 50,000 rows, the same pattern takes 8 seconds.
The subquery trap
# SQLAlchemy — an innocent-looking filter becomes a subquery
from sqlalchemy.orm import Session
active_users = session.query(User).filter(
User.id.in_(
session.query(Order.user_id).filter(Order.total > 100)
)
)
# Generated:
SELECT users.* FROM users
WHERE users.id IN (
SELECT orders.user_id FROM orders WHERE orders.total > 100
)
# Hand-written alternative using EXISTS:
SELECT u.* FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id AND o.total > 100
)
-- EXISTS and IN (subquery) are typically optimized identically by
-- PostgreSQL's planner — both stop after the first match via semi-join.
-- The real performance difference comes from how ORMs construct
-- these queries, not the SQL keywords themselves. The ORM's in_ method generates a subquery with IN. For small result sets — a few hundred rows — this is perfectly adequate. PostgreSQL handles the IN subquery efficiently, often converting it to a semi-join internally.
For large result sets, the picture changes. When the subquery returns 100,000 rows, IN materializes the entire result before comparing. EXISTS, by contrast, can stop after the first match for each outer row. On a table with 10 million orders where 2 million have totals above 100, the difference is 312ms versus 18ms. The ORM made a default choice. It was not the optimal one for this data distribution.
I should note — and this is the honest counterpoint the claim deserves — that PostgreSQL's query planner has grown increasingly sophisticated about optimizing IN subqueries. In PostgreSQL 16, the planner will sometimes convert IN (SELECT ...) to a semi-join automatically, achieving performance close to EXISTS. The gap is narrowing. But it has not closed, particularly on complex subqueries with multiple conditions, and when you write EXISTS explicitly, you remove the ambiguity entirely.
The bulk operations gap
# Django ORM — bulk_create (the "good" way)
Order.objects.bulk_create([
Order(customer_id=1, total=99.99, status='pending'),
Order(customer_id=2, total=149.50, status='pending'),
# ... 10,000 objects
])
# Generated SQL:
INSERT INTO orders (customer_id, total, status) VALUES
(1, 99.99, 'pending'),
(2, 149.50, 'pending'),
...
-- One statement. Django batches these automatically based on
-- PostgreSQL's 65535 bind parameter limit — the batch size
-- depends on the number of fields per row.
-- This is the good path. Not all ORMs offer it. -- Raw SQL — COPY protocol (the fast path)
COPY orders (customer_id, total, status)
FROM STDIN WITH (FORMAT csv);
1,99.99,pending
2,149.50,pending
...
\.
-- 10,000 rows: COPY takes ~15ms.
-- Multi-row INSERT takes ~120ms.
-- ORM row-by-row INSERT takes ~840ms.
-- The gap widens with row count. Bulk operations expose the widest gap between ORM convenience and database capability. PostgreSQL's COPY protocol is designed specifically for high-throughput data loading — it bypasses the normal query parsing pipeline and streams data directly into the table's storage layer. No ORM exposes this. The closest they come is multi-row INSERT, which is 8x slower than COPY on 10,000 rows and the gap grows linearly with row count.
Django's bulk_create, to its credit, does batch rows into multi-row INSERT statements. Rails' insert_all does the same. But some ORMs — particularly older versions of ActiveRecord and Hibernate in default configuration — will issue individual INSERT statements, one per row, each in its own round trip. 10,000 rows. 10,000 round trips. 10,000 transaction log flushes. The performance is exactly as grim as it sounds.
What the ORM simply cannot express
There is a category of query that falls outside the ORM's vocabulary entirely. Not because the ORM implements it poorly, but because the ORM does not implement it at all. This is the expressiveness ceiling, and it is worth examining directly.
Window functions with filtering
# Django ORM — window function (added in Django 2.0)
from django.db.models import F, Window
from django.db.models.functions import Rank, DenseRank
Order.objects.annotate(
rank=Window(
expression=DenseRank(),
partition_by=F('region'),
order_by=F('total').desc()
)
).filter(rank__lte=10)
# This does not work. Django cannot filter on window functions
# because SQL requires them in a subquery or CTE.
# You will get: FieldError or incorrect results. -- Raw SQL — top 10 orders per region, no fuss
WITH ranked AS (
SELECT o.*,
DENSE_RANK() OVER (
PARTITION BY o.region ORDER BY o.total DESC
) AS rank
FROM orders o
WHERE o.created_at >= '2025-01-01'
)
SELECT * FROM ranked WHERE rank <= 10;
-- Clean. Readable. The CTE handles the subquery requirement
-- that the ORM could not express. Window functions are one of PostgreSQL's most powerful features. DENSE_RANK, ROW_NUMBER, LAG, LEAD, NTILE, running totals, moving averages — these are the tools that turn a database query into an analytical engine. Most ORMs have partial support for defining window functions, but filtering on their results requires a CTE or subquery that the ORM cannot compose naturally.
Django added Window expressions in version 4.2, and they work for annotation — adding a window function column to each row. But the moment you need to filter on that column (give me only the top 10 per group, or only rows where the running total exceeds a threshold), you hit a wall. SQL requires the window function to appear in a subquery before you can filter on it. The ORM's query builder is not designed for this kind of composition.
LATERAL joins
-- LATERAL join: top 3 most recent orders per customer
SELECT c.name, o.id, o.total, o.created_at
FROM customers c
JOIN LATERAL (
SELECT *
FROM orders
WHERE customer_id = c.id
ORDER BY created_at DESC
LIMIT 3
) o ON true
ORDER BY c.name, o.created_at DESC;
-- This is not straightforward to express in Django or Rails.
-- Some ORMs (SQLAlchemy, Prisma) support it partially.
-- None make it pleasant. LATERAL is PostgreSQL's mechanism for correlated subqueries in the FROM clause — it allows each row of the outer query to reference a different subquery result. The "top N per group" pattern is the canonical use case, and it is remarkably common: the three most recent orders per customer, the five highest-scoring comments per post, the latest status change per account.
Without LATERAL, solving this requires either window functions in a CTE (workable but verbose) or multiple queries stitched together in application code (slow and fragile). With LATERAL, it is a single, clean query that the planner can optimize effectively. No major ORM expresses LATERAL natively. If you need this pattern, you are writing raw SQL.
I should be honest about the frequency here. Most applications do not need LATERAL joins or window function filtering in their day-to-day queries. If your application is CRUD with moderate reporting — which describes the majority of web applications — you may never encounter the expressiveness ceiling. The ORM handles your actual workload perfectly well, and reaching for raw SQL would be premature. The ceiling exists, but not every household bumps into it.
The benchmark results
Ten million orders, 500,000 customers, PostgreSQL 16, measured on identical hardware with warm caches and representative data distribution. The ORM column uses Django ORM with default settings. The patterns are similar across frameworks — I verified against Rails, SQLAlchemy, and Prisma, and the numbers fall within 15% of Django's in every case. The differences between ORMs are smaller than the differences between ORM and raw SQL on the patterns where a gap exists.
| Query pattern | ORM | Raw SQL | Overhead | Assessment |
|---|---|---|---|---|
| Simple filter (WHERE status = ...) | 1.2ms | 1.1ms | 9% | Negligible |
| Join + filter | 2.4ms | 2.3ms | 4% | Negligible |
| Aggregation (GROUP BY, SUM, COUNT) | 48ms | 47ms | 2% | Negligible |
| N+1 pattern (100 related lookups) | 124ms | 2.3ms | 5,291% | ORM bug, not inherent |
| Subquery vs EXISTS | 312ms | 18ms | 1,633% | ORM chose wrong pattern |
| Bulk insert (10,000 rows) | 840ms | 120ms | 600% | ORM inserts row-by-row |
| Window function + filter | N/A | 34ms | - | ORM cannot express this |
| LATERAL join (top-N per group) | N/A | 12ms | - | ORM cannot express this |
| Bulk insert via COPY | N/A | 15ms | - | ORM has no access to COPY |
The first three rows are the expected finding: for standard queries, the ORM overhead is single-digit percentages. The Python object creation adds a fraction of a millisecond. The SQL is identical. There is nothing to optimize here, and time spent attempting to would be time wasted.
The middle three rows are where the debate should actually focus. The N+1 pattern, the subquery choice, and bulk operations — these are not "ORM overhead" in the sense of query generation cost. They are architectural patterns that the ORM encourages or fails to prevent. The distinction matters because the fix for each is different: N+1 requires eager loading (an ORM feature), subquery optimization requires choosing the right SQL pattern (which may require raw SQL), and bulk operations require PostgreSQL-specific protocols (which definitely require raw SQL).
The last three rows represent the expressiveness ceiling — queries the ORM cannot formulate at all. The overhead column reads "N/A" because there is no ORM equivalent to compare against. These are PostgreSQL features that the ORM layer does not expose. If your workload requires them, raw SQL is not an optimization choice. It is the only choice.
The honest counterpoint: what ORMs do well
I have spent considerable time examining where ORMs fall short. A waiter who overstates his case is no waiter at all, and I would be doing precisely that if I did not give equal attention to where ORMs earn their keep.
SQL injection prevention
This is not a minor point. SQL injection remains in the OWASP Top 10 because developers continue to write code like f"SELECT * FROM users WHERE name = '{user_input}'". The ORM parameterizes every query by default. There is no way to accidentally pass unsanitized user input into the query. This alone justifies the ORM for any application that accepts user input — which is to say, every application.
Raw SQL can be equally safe, but it requires discipline. Every query must use parameterized placeholders. Every developer on the team must understand why. Every code review must check for it. The ORM makes the safe path the default path. That matters, particularly on larger teams where not every developer has internalized the risks.
Schema migrations
When you rename a column in your ORM model, the migration framework generates the appropriate ALTER TABLE statement. When you rename a column used in 47 raw SQL queries scattered across your codebase, you must find and update all 47. Miss one, and you have a production error that only manifests when that particular code path executes.
This is the maintainability argument, and it is formidable. A codebase with 200 raw SQL queries is a codebase where schema changes are high-risk operations. A codebase with 200 ORM queries that use model references is a codebase where the compiler (or at least the test suite) catches the breakage before deployment.
Team velocity and safety
A junior developer writing Django ORM queries will produce correct, safe, reasonably performant code on their first day. The same developer writing raw SQL will produce code that works but may contain injection vulnerabilities, missing parameterization, unclosed connections, or transaction handling bugs. The ORM constrains the space of possible mistakes. For teams that hire frequently, this constraint is valuable.
"The ORM did not fail. It did exactly what was asked. It was simply asked poorly."
— from You Don't Need Redis, Chapter 3: The ORM Tax
When does each approach win?
| Dimension | Verdict | Why |
|---|---|---|
| CRUD operations | ORM wins | Less code, less risk of SQL injection, schema migrations |
| Simple queries | Tie | ORM generates identical SQL — overhead is only Python/Ruby object creation |
| Complex aggregations | Slight edge to raw | ORM can generate these, but the syntax is often more awkward |
| N+1 prevention | Raw wins by default | Raw SQL joins explicitly. ORMs require deliberate eager loading |
| Subquery optimization | Raw wins | You choose EXISTS vs IN vs lateral join. The ORM guesses |
| Bulk operations | Raw wins | COPY, multi-row INSERT, UPDATE FROM — ORMs often lack native support |
| Window functions / CTEs | Raw wins | ORMs have partial support at best. Raw SQL is the native language |
| Maintainability | ORM wins | Schema changes propagate. Raw SQL is scattered strings |
| Security (SQL injection) | ORM wins | Parameterized by default. Raw SQL requires discipline |
| Team onboarding | ORM wins | Junior developers write safer code. ORM constrains mistakes |
The pattern is clear: ORMs win on developer productivity, safety, and maintainability. Raw SQL wins on specific performance-sensitive patterns and on queries that require PostgreSQL features the ORM cannot express. The optimal approach is not one or the other — it is knowing which queries warrant the escape hatch.
How to identify which queries need raw SQL
If you will permit me, I should like to suggest a diagnostic approach. Not every query deserves investigation, and the goal is to find the ones that do — efficiently, without descending into premature optimization.
Step 1: Count your queries
# Django — count queries in development
from django.db import connection, reset_queries
from django.conf import settings
settings.DEBUG = True
reset_queries()
# ... your view logic here ...
print(f"Queries executed: {len(connection.queries)}")
# If this number is higher than you expected,
# you have N+1 patterns. Fix those before
# worrying about ORM overhead. Before you examine any individual query, establish a baseline. How many queries does each endpoint or page execute? If your product listing page runs 3 queries, the ORM is doing its job. If it runs 247, you have N+1 patterns that no amount of raw SQL will fix — only eager loading will.
Step 2: Compare execution plans
-- Run this on your ORM-generated query:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT "orders"."id", "orders"."total", "customers"."name"
FROM "orders"
INNER JOIN "customers" ON ("orders"."customer_id" = "customers"."id")
WHERE ("orders"."status" = 'pending'
AND "orders"."created_at" >= '2025-01-01');
-- Then run the same EXPLAIN on your hand-written version.
-- Compare the output. If the plans are identical, the
-- performance is identical. The ORM is not your problem. For the queries that are actually slow (identified via pg_stat_statements, sorted by total_exec_time), run EXPLAIN ANALYZE on the ORM-generated SQL. Then write the raw SQL equivalent and run the same EXPLAIN. If the plans are identical — same join strategy, same index usage, same estimated costs — the ORM is not your problem. The problem is the plan itself: a missing index, stale table statistics, or an inherently expensive operation.
If the plans differ — the ORM uses IN where you would use EXISTS, or it generates a subquery where you would use a join — then you have found a query that benefits from raw SQL.
Step 3: Identify expressiveness gaps
If you find yourself writing convoluted ORM code to express a query that is simple in SQL — chaining five method calls to produce what would be a straightforward CTE — that is a signal. The ORM is being asked to do something outside its design space. Write the raw SQL. It will be more readable, more maintainable, and often more performant, because you can choose the optimal query shape instead of accepting what the ORM's API surface permits.
The pragmatic answer
Use the ORM for the majority of your queries. I have seen the number quoted as "95%," and in my experience that figure is approximately correct for most web applications. The ORM generates good SQL, prevents SQL injection, handles migrations, and makes your code readable to every developer on your team. The single-digit-percentage overhead on standard queries is not worth optimizing — your time is better spent elsewhere.
Use raw SQL for the specific queries where the ORM's default patterns fail: bulk operations where you need COPY, complex subqueries where you need EXISTS or LATERAL, window functions that require CTE composition, recursive queries, queries with PostgreSQL-specific features like DISTINCT ON or array_agg with custom ordering, or any pattern where you know the optimal SQL and the ORM cannot express it.
Above all, fix the N+1 patterns. The ORM's lazy loading default is the single largest source of "ORM performance problems," and it has nothing to do with SQL generation overhead. It is a query count problem, not a query quality problem. Use eager loading everywhere. Check your query counts in development. The difference between 1 query and 200 queries dwarfs every other optimization on this page.
And when you do use raw SQL, use it through the ORM's raw query interface — Model.objects.raw() in Django, find_by_sql in Rails, text() in SQLAlchemy, $queryRaw in Prisma. You still get parameterized queries (protection against injection), connection management (no leaked connections), and transaction handling (no orphaned locks). You lose the ORM's query builder. You keep everything else.
A note on the query builder alternatives
The debate is often framed as ORM versus raw SQL, as though these are the only two options. They are not. Query builders — libraries that construct SQL programmatically without the object-relational mapping layer — occupy a productive middle ground.
Knex.js in the JavaScript ecosystem, jOOQ in Java, and SQLAlchemy Core (as distinct from SQLAlchemy ORM) in Python all provide composable query construction without the overhead of object hydration or the constraints of model-based APIs. You get parameterization, composability, and the ability to express any SQL pattern the database supports — without the identity map, change tracking, or lazy loading that create the ORM's performance pitfalls.
I do not raise this to add complexity to an already complex decision. I raise it because the honest answer to "ORM or raw SQL?" is sometimes "neither — a query builder gives you the safety of the ORM and the expressiveness of raw SQL, at the cost of giving up model-level conveniences like migrations and relationships." For data-intensive applications with complex reporting, this trade-off is often the right one.
Where a proxy fits in
Gold Lapel operates at the SQL level — it sees the queries your ORM generates, not the ORM code that generated them. This means it catches the performance problems that live in the gap between what the ORM produces and what PostgreSQL needs:
- N+1 patterns — detected by observing repeated identical queries with different parameter values, regardless of which ORM sent them. The proxy sees the pattern at the wire level: the same query template, executed dozens of times in rapid succession, differing only in the parameter. No application instrumentation required.
- Missing indexes — identified from the query patterns in traffic, not from the application code. The proxy observes which columns are filtered and joined on, cross-references with existing indexes, and creates what is missing.
- Suboptimal patterns — when the ORM generates
IN (SELECT ...)andEXISTSwould be faster for the data distribution, the proxy can rewrite at the wire level. The ORM never changes. The SQL that reaches PostgreSQL does. - Repeated aggregations — materialized automatically when the same expensive query appears frequently. No manual
CREATE MATERIALIZED VIEW. No cron job for refresh.
The ORM generates the SQL. PostgreSQL executes it. The space between them — where patterns emerge that neither can see individually — is where optimization lives. The ORM does not know that it has generated 200 queries instead of 1. PostgreSQL does not know that the same aggregation has been computed 48,000 times today. A proxy sitting between them sees both.
Frequently asked questions
Terms referenced in this article
There is a companion piece that examines this question across every major language ecosystem, with identical datasets and hardware. If you would like the numbers rather than the argument, the comprehensive ORM performance benchmark is where I have placed them.