Chapter 3: The ORM Tax: How Your Framework Betrays Your Database
I regret to inform you that your ORM has been submitting queries on your behalf that require — how shall I put this — a frank discussion.
You have, I trust, experienced the following: a page that loads in four seconds. The application code is straightforward — a loop displaying 25 items with their related data. Nothing exotic. The database has proper indexes. The server is not under unusual load. Then you enable query logging, and you discover, to your considerable dismay, that PostgreSQL received 47 separate SQL queries to render a single page.
Forty-seven. For a page that displays twenty-five items.
The explanation is not a bug in your code. It is not a misconfiguration of your database. It is the natural, predictable, intended behavior of your Object-Relational Mapper — doing exactly what it was designed to do, in a context where that design produces spectacularly inefficient results.
Object-Relational Mappers optimize for developer convenience. Readable code. Type safety. Migration management. Protection against SQL injection. These are genuine virtues, and I do not dismiss them lightly. What ORMs do not optimize for is query efficiency. The structural mismatch between object-oriented programming — which navigates from one object to another through references, one relationship at a time — and relational databases — which join entire tables in a single operation, combining thousands of rows in one pass — produces SQL that a database engineer would never write by hand. This is not a bug in your ORM. It is a design trade-off. But it is a trade-off most developers never see, because the ORM hides the SQL it generates behind an abstraction that looks clean, feels modern, and performs terribly.
I call this the ORM Tax: the cumulative performance cost of ORM-generated SQL versus what a competent query would produce. Every application using an ORM pays it. Most applications don't know they're paying it.
I shall now show you exactly what seven popular ORMs send to your database when asked to perform the same operation, why the queries are slow, how to address the matter within each ORM's own tools, and — when those tools prove insufficient — how a different approach renders the problem structurally irrelevant.
The Nature of the Problem
Before we examine each ORM individually, allow me to describe the pattern they share. It has a name — the N+1 query problem — and it is, in my experience, responsible for more application slowdowns than any other single cause.
Consider a blog. You wish to display 25 posts, each with its author's name. The ORM retrieves the posts in a single query — perfectly reasonable. Then, as your template or view iterates over the posts and accesses each post's author, the ORM fetches each author individually. One query for the posts. Twenty-five queries for the authors. Twenty-six total, where a single query with a JOIN would have sufficed.
This is the N+1 pattern: one initial query plus N subsequent queries, one for each item in the result set. Scale the relationships — posts with authors, comments, tags, categories — and a single page render can produce hundreds of queries. I have observed, in production systems, pages generating over 400 database round trips to render what amounts to a table with a few columns.
The pattern arises because ORMs default to lazy loading — a behavior I call the N+1 habit. When you load a post object, the ORM does not load the author. It installs a placeholder: a proxy object, a deferred reference, a promise that fetches the author only when you access it. For a single post, this is sensible. Why load the author if you never display their name? The efficiency is genuine.
The difficulty emerges in loops. When you iterate over 25 posts and access each author, the ORM cannot see the loop. It does not know you are about to ask for 25 authors in sequence. It fetches each one individually because that is what it was asked to do — one object at a time, one query at a time, one network round trip at a time.
Every ORM provides an eager loading mechanism that addresses this: a way to tell the ORM in advance which relationships you will need, so it can fetch them in bulk — either in a single JOIN or a small number of batched queries. The syntax varies by framework. The principle is identical everywhere. Section by section, I shall show you each one.
But first, a practical suggestion. Enable your query log before proceeding. What follows will be considerably more instructive if you can observe your own application's behavior alongside the examples I present.
Diagnosing the N+1 in Any Framework
Framework How to Enable Query Logging Detection Tool Django LOGGINGsetting withdjango.db.backendsat DEBUG leveldjango-debug-toolbar Rails Default in development; config.active_record.verbose_query_logs = trueBullet gem Spring Boot spring.jpa.show-sql=true+logging.level.org.hibernate.SQL=DEBUGHibernate Statistics Laravel DB::listen(function ($q) { Log::info($q->sql); });Laravel Debugbar, preventLazyLoading()Prisma prisma.$on('query', (e) => console.log(e.query))Prisma query events Sequelize logging: console.login constructor options— GORM db.Debug()orLogger: logger.Default.LogMode(logger.Info)—
The Examination
I shall now present seven ORMs performing the same operation, each revealing a different facet of the N+1 problem and its remedy.
The scenario: a content platform with posts, authors, and comments. We wish to retrieve 25 posts, each with the author's name, the comment count, and the most recent comment date. This is a routine dashboard-style query — the kind every web application runs dozens of times per minute, the kind that determines how fast your pages feel.
Django
The code a developer writes:
posts = Post.objects.all()[:25]
for post in posts:
print(post.author.name) # triggers a query
print(post.comments.count()) # triggers another query What PostgreSQL receives: 1 query for the posts, 25 queries for the authors, 25 queries for the comment counts. Fifty-one queries. The developer wrote three lines of Python. The database received fifty-one SQL statements.
The fix:
posts = (Post.objects
.select_related('author')
.prefetch_related('comments')
.annotate(
comment_count=Count('comments'),
latest_comment=Max('comments__created_at')
)[:25]) After: 2 to 3 queries. select_related performs a SQL JOIN for foreign key relationships — the author is fetched in the same query as the posts. prefetch_related executes a separate batched query for reverse and many-to-many relationships. annotate adds the aggregation — comment count and most recent date — in a single pass, computed by PostgreSQL rather than in Python.
The ceiling: even with Django's full toolkit, complex dashboard queries outgrow annotate and Subquery. Multi-level aggregation with window functions — month-over-month growth, running totals, percentile calculations — is essentially impossible without dropping to raw SQL. Django's ORM is among the most capable of the seven examined here, but it remains an ORM, with an ORM's structural limitations.
Rails ActiveRecord
posts = Post.limit(25)
posts.each do |post|
puts post.author.name # N+1
puts post.comments.count # N+1
end Fifty-one queries. The fix:
posts = Post.includes(:author, :comments)
.limit(25) After: 1 to 2 queries. Rails' includes automatically selects between two strategies: preloading (a separate WHERE id IN (...) query for the association, used by default) and eager loading (a LEFT JOIN, used when the query includes conditions on the associated table). The selection is intelligent and usually correct.
The Bullet gem — which I consider an essential development dependency — detects N+1 queries automatically and raises warnings in the console, the browser, or your notification channel of choice. It cannot fix the problem. It makes the problem impossible to ignore, which in my experience is the more valuable service.
The ceiling: aggregation across included associations requires group, having, or raw SQL fragments injected via select. Rails handles simple eager loading with grace. Complex aggregation demands more than ActiveRecord can provide elegantly.
Spring Boot / Hibernate JPA
List<Post> posts = postRepository.findAll(PageRequest.of(0, 25))
.getContent();
// In the template or service:
for (Post post : posts) {
post.getAuthor().getName(); // triggers proxy fetch
} Hibernate's default FetchType.LAZY on @OneToMany — and the best-practice override to LAZY on @ManyToOne, which the JPA spec defaults to EAGER — means every relationship traversal triggers a proxy fetch. The N+1 is particularly insidious in Java because the proxy mechanism makes database calls look like ordinary method calls. post.getAuthor() returns what appears to be an Author object. It is, in fact, a Hibernate proxy that will execute a SELECT statement the moment you access any of its fields. The code compiles. The IDE shows no warnings. The database receives 26 queries.
The fix:
@EntityGraph(attributePaths = {"author", "comments"})
List<Post> findTop25ByOrderByCreatedAtDesc(); Or in JPQL:
@Query("SELECT p FROM Post p " +
"JOIN FETCH p.author " +
"LEFT JOIN FETCH p.comments " +
"ORDER BY p.createdAt DESC")
List<Post> findRecentWithDetails(Pageable pageable); After: 1 query with JOINs. Hibernate's @EntityGraph is, I confess, the most elegant eager loading API of the seven ORMs examined here. You declare the graph of relationships you want, and Hibernate constructs the optimal fetch strategy. It is a pity that discovering this feature requires navigating documentation of considerable density.
A critical gotcha: eagerly fetching multiple collections simultaneously can produce a Cartesian product — a cross-join explosion where the result set contains every combination of associated rows. If a post has 10 comments and 5 tags, the joined result contains 50 rows for that single post. Vlad Mihalcea, who has written a small library's worth of articles on Hibernate performance, recommends using @BatchSize or separate queries for multiple collections rather than fetching them all in a single JOIN.
The ceiling: JPQL supports more SQL features than most ORM query languages, but window functions and CTEs remain outside its reach.
Laravel Eloquent
$posts = Post::take(25)->get();
foreach ($posts as $post) {
echo $post->author->name; // N+1
echo $post->comments->count(); // N+1
} Fifty-one queries. The fix:
$posts = Post::with(['author', 'comments'])
->withCount('comments')
->take(25)
->get(); After: 2 to 3 queries. with() eager-loads the specified relationships. withCount() adds a subquery-based count without loading the actual comment records — efficient when you need the number but not the data.
Laravel offers a feature that I find admirable: Model::preventLazyLoading(), introduced in Laravel 9. When enabled in your development environment, it throws a LazyLoadingViolationException whenever a relationship is accessed without being eager-loaded. It does not fix N+1 queries. It makes them impossible to deploy without noticing. One wishes every framework offered the same discipline.
// In AppServiceProvider::boot()
Model::preventLazyLoading(!app()->isProduction()); The ceiling: withCount handles simple aggregation. Complex multi-table aggregation still requires DB::raw() or query builder expressions that bypass Eloquent's relationship system.
Prisma (Node.js)
Prisma occupies a unique position in this examination. It does not have lazy loading. There are no proxy objects, no deferred references, no hidden database calls behind innocent-looking property access. Relationships must be explicitly requested via the include parameter. This is, architecturally, the correct design — it makes the data-fetching intent visible in the code.
The N+1 problem in Prisma is therefore not a framework behavior. It is a developer behavior — writing application code that makes separate database calls in a loop:
// The manual N+1 — developer-created
const posts = await prisma.post.findMany({ take: 25 });
for (const post of posts) {
const author = await prisma.user.findUnique({
where: { id: post.authorId }
});
} The fix:
const posts = await prisma.post.findMany({
take: 25,
include: {
author: true,
comments: {
orderBy: { createdAt: 'desc' },
take: 1
},
_count: { select: { comments: true } }
}
}); After: Prisma generates the appropriate JOINs or batched queries internally. The TypeScript type system provides a meaningful advantage here — post.author is fully typed when included, with complete autocomplete and compile-time checking. If you access a relationship that wasn't included, TypeScript tells you before the code runs. This is the N+1 detection tool that Prisma's design provides: the type system itself.
The ceiling: Prisma's native aggregation covers the basics — _count, _sum, _avg, _min, _max — but stops there. Window functions, grouped calculations across JOINs, CTEs, and conditional aggregation require prisma.$queryRaw, which returns untyped results and bypasses Prisma's query engine entirely.
Sequelize (Node.js)
const posts = await Post.findAll({ limit: 25 });
for (const post of posts) {
const author = await post.getAuthor(); // N+1
} Twenty-six queries. The fix:
const posts = await Post.findAll({
limit: 25,
include: [
{ model: Author },
{
model: Comment,
attributes: [],
separate: true // critical for hasMany performance
}
],
attributes: {
include: [
[sequelize.fn('COUNT', sequelize.col('comments.id')),
'commentCount']
]
},
group: ['Post.id', 'Author.id']
}); After: 1 to 2 queries. The separate: true option deserves particular attention. By default, Sequelize combines associated table queries using JOINs — which, for hasMany relationships with large result sets, produces enormous intermediate results that Sequelize must then deduplicate in JavaScript. The deduplication overhead can be devastating. GumGum, the ad-tech company, documented their experience in a detailed blog post: an endpoint that took 2.3 seconds dropped to 200 milliseconds — a 10x improvement — after restructuring their Sequelize includes and leveraging separate queries for hasMany associations. A separate case documented an 11-second endpoint reduced to 160 milliseconds through the same technique.
The separate option exists in the Sequelize documentation. Finding it requires, in my experience, a certain determination that not all developers possess. A GitHub issue from 2019 suggested making separate: true the default for hasMany associations. It remains, as of this writing, an opt-in behavior.
The ceiling: Sequelize's aggregation syntax — sequelize.fn, sequelize.col, sequelize.literal — is verbose, fragile, and difficult to maintain. Complex aggregations become essentially indistinguishable from raw SQL wrapped in Sequelize helper functions.
GORM (Go)
var posts []Post
db.Limit(25).Find(&posts)
for i := range posts {
db.Model(&posts[i]).Association("Author").Find(&posts[i].Author)
} Twenty-six queries. The fix:
var posts []Post
db.Preload("Author").Preload("Comments").Limit(25).Find(&posts) After: 3 queries — one per table. GORM's Preload issues a separate WHERE id IN (...) query for each preloaded association. It is effective but limited: you cannot filter, sort, or aggregate the preloaded data within the Preload call. Complex queries — anything involving JOINs with conditions, aggregations, or subqueries — require db.Raw(), which is GORM's forthright acknowledgment that the ORM cannot help you further.
The Go ecosystem, I have observed, tends to value brevity in its documentation. One appreciates the efficiency. One occasionally wishes for elaboration. GORM's documentation on eager loading spans perhaps a page and a half. The Go community's primary resource for GORM performance guidance appears to be GitHub issues, where developers troubleshoot performance problems through trial and error with the support of other developers engaged in the same process.
The ceiling: GORM has the lowest aggregation capability of the seven ORMs examined. Any query involving GROUP BY, HAVING, window functions, or computed columns requires raw SQL. For Go applications with complex read requirements, raw pgx queries or sqlc-generated code are frequently more practical than attempting to coerce GORM into producing the right SQL.
The Same Query, Seven Ways
ORM Naive Queries Fixed Queries Fix Method Native Aggregation? N+1 Detection Django 51 2–3 select_related+prefetch_related+annotatePartial django-debug-toolbar ActiveRecord 51 1–2 includesPartial Bullet gem Hibernate/JPA 26 1–2 @EntityGraph/JOIN FETCHYes (JPQL) Hibernate Statistics Eloquent 51 2–3 with()+withCount()Partial preventLazyLoading()Prisma Manual N+1 1–2 include+_countLimited TypeScript types Sequelize 26 1–2 include+separate+sequelize.fnPartial (verbose) — GORM 26 3 PreloadNo db.Debug()Seven ORMs. The same data model. The same operation. Query counts ranging from 26 to 51 in the naive case, and from 1 to 3 after optimization. The pattern is universal: the default behavior is expensive, the fix is available, and the fix has limits.
The Ceiling
I have shown you how to fix the N+1 problem within each ORM's tools. I should now be honest about the limits of those fixes, because this is the point that most ORM performance articles neglect — the point where the conventional advice runs out and a different kind of thinking is required.
Eager loading solves the most common ORM performance problem. It does not solve all of them. Consider a reporting dashboard that must display: total revenue by product category for the last 90 days, with month-over-month growth percentages, filtered by region, and sorted by the magnitude of change. This query involves multiple JOINs across four or five tables, window functions for the growth calculations, conditional aggregation for the category breakdowns, and possibly a CTE for intermediate computations that feed the final result.
No ORM handles this gracefully. Django's annotate and Subquery stretch to cover parts of it but collapse under the weight of window functions and nested aggregations. Hibernate's JPQL handles more than most, but window functions and CTEs are outside its grammar. Prisma, Sequelize, and GORM don't attempt it at all — they route you to raw SQL and wish you well.
The conventional advice at this point is: "Drop to raw SQL for complex queries." This works. It also defeats the ORM's value proposition. Raw SQL strings embedded in application code are invisible to the ORM's migration system, untested by its type checking, fragile in the face of schema changes, and a maintenance burden for every developer who encounters them six months later and must deduce their intent. The developer who wrote the raw SQL understood it. The developer who inherits it may not.
This is what I call the ORM ceiling: the point at which eager loading, query optimization, and ORM-specific workarounds are no longer sufficient, and the developer faces a choice between raw SQL (effective but inelegant) and accepting poor performance (unacceptable but convenient).
There is, however, a third option — and it is the reason this book exists.
A materialized view pre-computes the complex query once. The JOINs, the aggregations, the window functions, the CTEs — all of it executes during refresh and the results are stored as a physical table. Your ORM reads from that table using the same model class, the same query methods, the same type safety it uses for any other table in your schema. The ORM sees a flat table with exactly the columns your application needs. No raw SQL in your application code. No N+1 problems — the data is already aggregated. No complex JOINs at query time — they happened during the materialized view's refresh, in a migration file where complex SQL belongs.
The ORM's weakness — its affinity for simple queries on flat tables — becomes its strength, because the materialized view is a flat table of pre-computed results.
The arrangement I recommend is straightforward:
Your ORM handles CRUD operations — the 90% of database interaction that ORMs handle well. Creating records, updating fields, deleting rows, simple lookups by primary key or indexed column. This is what ORMs were built for, and they do it with genuine competence.
Materialized views handle complex reads — the 10% of queries that determine your application's perceived performance. Dashboards, reporting endpoints, aggregated API responses, search indexes. The queries that ORMs struggle with are precisely the queries materialized views are designed for.
pg_stat_statements tells you which queries belong in which category. Sort by total_exec_time descending, and the top five entries will reveal — with scientific precision — which queries are costing you the most. Cross-reference with your ORM's query log to find the application code responsible.
Use each tool for what it does well. Expect none to do everything. If I may be direct: this separation of concerns is the reason this book exists. Not because ORMs are wrong — they serve their purpose with considerable competence. But because there is a way to serve complex data that works with your ORM rather than against it, and that way has been available in your database since 2013.
Chapter 4 will teach you everything you need to know about it.
Before We Proceed
That concludes Part I. We have examined the problem from three angles, and in each case the diagnosis has pointed in the same direction.
Your database is underperforming — not because PostgreSQL is slow, but because it has capabilities you haven't been introduced to. Your infrastructure is overstaffed — not because the services are poor, but because many of them duplicate work PostgreSQL already does. And your ORM, while serving its purpose admirably for routine operations, is producing SQL for complex reads that neither it nor your database was designed to handle that way.
Should I stop using an ORM?
No. ORMs provide genuine value: type safety, migration management, protection against SQL injection, developer productivity for the vast majority of database operations. The problem is not the ORM. The problem is using the ORM for work it was never designed to do — the complex aggregations, the multi-table rollups, the dashboard queries that join half your schema. Use the ORM for CRUD. Use materialized views for complex reads. Let each tool do what it does well.
How do I find the worst ORM queries in my application?
Enable pg_stat_statements — one line in your PostgreSQL configuration. Sort by total_exec_time descending. The top five entries will almost certainly be ORM-generated aggregations or N+1 patterns on your most-visited pages. Cross-reference with your ORM's query log — the sidebar earlier in this chapter shows how to enable it for each framework — to find the application code responsible. Chapter 18 provides the complete diagnostic workflow.
Can materialized views really fix this without changing my application code?
Yes. A materialized view appears to your ORM as an ordinary database table. You map a model to it: managed = False in Django, a read-only model class in Rails, @Entity @Immutable in Spring Boot, HasNoKey() with ToView() in EF Core, a view declaration in Prisma's schema. You query it with the same ORM methods you use for everything else. The complex SQL lives in a database migration, not in your application code. Your ORM never knows the difference — and that is precisely the point.
In Part II, I shall introduce the remedy. It begins with a feature of PostgreSQL that I consider the most powerful and least understood tool in your existing database — a feature that, once properly introduced, changes how you think about query performance, caching, and application architecture.
Chapter 4 is devoted to it entirely. If you'll follow me — I do believe you'll find it rather satisfying.