12k
All articles

When an ORM Is the Wrong Tool

When ORMs become a bottleneck, use raw SQL for N+1 queries, window functions, CTEs, bulk writes, and safer parameterized escapes.

OpenReplay Team
OpenReplay Team
When an ORM Is the Wrong Tool

An ORM is the right default for CRUD and the wrong tool the moment your query stops looking like object access and starts looking like a report.

You probably know the moment it turns: a list endpoint that felt fine in staging takes four seconds in production, and the query log is full of near-identical SELECTs nobody wrote by hand. Window functions, CTEs, multi-join aggregations, and vendor-specific operators are exactly where an ORM’s generated SQL turns inefficient or impossible, and where dropping to raw SQL earns its keep. This article draws the line precisely: where object-relational mapping is the correct default, where it quietly becomes the bottleneck, and how to reach past it without giving up injection safety.

Key Takeaways

  • ORMs are the correct default for the ~80% of simple CRUD: they cut boilerplate, parameterize inputs automatically, and stay database-agnostic.
  • Raw SQL is not inherently faster than an ORM. It wins specifically when the ORM’s generated query is the bottleneck, on hot paths, bulk operations, or N+1 patterns.
  • The N+1 problem is the most common way an ORM silently becomes the wrong tool; fix it first with eager loading, and reach for raw SQL only when even the eager-loaded shape is wrong.
  • Leaving the ORM doesn’t mean removing it. Drop to raw SQL through its own escape hatch: Django’s connection.cursor() or Manager.raw(), SQLAlchemy’s text(), Prisma’s TypedSQL.
  • When you write raw SQL you inherit injection safety, so always pass user input through placeholders (%s in psycopg, $1 in Postgres/SQLx) and never concatenate it into the query string.

Raw SQL, query builders, ORMs: an abstraction spectrum

“ORM vs raw SQL” was never a binary. Data access is a spectrum from full control to full convenience, with a middle tier most comparisons skip. At one end, raw SQL gives you the database’s native language with no translation layer. At the other, ORMs like Django’s ORM, ActiveRecord, Hibernate, Prisma, Sequelize, and SQLAlchemy map rows to objects and generate SQL for you. In between sit query builders.

A query builder formalizes query patterns as chainable methods while staying close to the SQL it emits. Most ORMs also expose a way to hand the database a raw string, which drops the escaping their normal query methods do for you and reopens the door to SQL injection. A builder is a different tool: it composes SQL programmatically without pretending to be object access. Knex is an actively maintained JavaScript query builder, on the 3.3.0 line since June 2026 according to its changelog; on the JVM, jOOQ is a typesafe SQL DSL, currently on the 3.21 line, whose Open Source Edition targets JDK 21. Neither is an ORM, and both keep parameterization intact, which is the point. When the ORM abstraction fights you, the builder tier is often the right step down before hand-written SQL.

When is an ORM the wrong tool?

The signal to switch isn’t a feeling: it’s specific. Reach past the ORM when you hit one of these five patterns:

  1. Analytical and report-shaped queries. Window functions, recursive CTEs, GROUP BY ... HAVING rollups, and multi-join reports are where generated SQL turns inefficient or impossible to express. An ORM optimizes for object access, not OLAP-shaped output.
  2. Hot paths and bulk operations. On a high-traffic endpoint or a batch UPDATE/INSERT, per-row save() calls and extra round-trips add up. A single set-based statement replaces hundreds of ORM writes.
  3. The N+1 query trap. Covered in detail below: the single most common ORM performance failure.
  4. Database-specific features. Postgres JSONB operators like @> and ->>, full-text search with tsvector/tsquery, LATERAL joins, and PostGIS geospatial functions are features many ORMs can’t fully or idiomatically express. Some ORMs expose helpers (Django’s contrib.postgres), but the coverage is partial.
  5. Opaque, “magic” behavior. When you can’t see or tune the SQL the ORM emits, debugging and performance work become guesswork. That is the object-relational impedance mismatch showing up as a real cost, and it has a security edge: the raw-query methods most ORMs provide sit outside their own escaping, so interpolating a value into one leaves you exposed.

The N+1 query problem, named and fixed

The N+1 problem is the most common way an ORM quietly becomes the wrong tool: lazy loading fires one query per row, so a list of 100 items silently becomes 101 round-trips. The loop looks innocent:

# One query for authors, then one MORE per author for their books
for author in Author.objects.all():
    print(author.name, author.books.count())

The fix is eager loading, not raw SQL. Django’s select_related and prefetch_related collapse those round-trips into a JOIN or a single IN query:

# Two queries total, regardless of author count
authors = Author.objects.prefetch_related("books")

Fix N+1 with eager loading first, and reach for raw SQL only when even the eager-loaded shape is wrong, for example when you need a windowed aggregate per author that the ORM would express as another round-trip. Inefficient ORM queries rarely announce themselves in your code; they surface as slow API responses and slow page loads. A session-replay tool like OpenReplay shows the slow network request in the session timeline, pointing you to the endpoint whose backend query needs attention: the symptom’s location, not the query itself. For the deeper tradeoff, see OpenReplay’s guide to preventing SQL injection.

What do you give up when you write raw SQL?

When you write raw SQL you inherit the one job the ORM was doing for you silently: injection safety. Always pass user input through parameter placeholders and never concatenate it into the query string. Django’s guide to performing raw SQL queries sets out the mechanics: cursor.execute() takes %s placeholders plus a separate list of values, and the driver escapes each value on the way in, so it never becomes part of the statement text.

# Safe: %s is the psycopg/DB-API placeholder, not string formatting
from django.db import connection
with connection.cursor() as cursor:
    cursor.execute("SELECT * FROM book WHERE author = %s", [user_input])
    rows = cursor.fetchall()

Leave the placeholders bare: quoting %s inside the SQL string throws that protection away. Rust’s SQLx takes its placeholder from the database, so $1 in Postgres but ? in MySQL, MariaDB, and SQLite. Beyond injection, you also take on more boilerplate, tighter coupling to one SQL dialect, and manual mapping of result rows back to objects.

You don’t have to give up the safety net to give up the ORM. Compile-time-checked tools keep it: SQLx (0.9) verifies queries against the schema before the app runs and its own docs state it is not an ORM; jOOQ (3.21) does the same on the JVM. Query builders sit in between. “ORM vs raw SQL” is a false binary: the real axis is how much abstraction each specific query deserves.

The pragmatic ORM vs raw SQL verdict

Use the ORM for the ~80% of simple CRUD and drop to raw SQL through its own escape hatch for the specific queries that earn it. Leaving the ORM doesn’t mean removing it. Django documents three routes: RawSQL for slotting a parameterized fragment into an ORM query, Manager.raw() for a raw query that still returns model instances, and connection.cursor() for bypassing the model layer altogether. SQLAlchemy exposes text(); Prisma ships TypedSQL, currently a preview feature, plus $queryRaw for untyped access.

The decision reduces to a short table:

SituationReach for
CRUD, forms, standard relationsORM
Cross-dialect portability mattersORM or query builder
Multi-join reports, window functions, CTEsRaw SQL
Hot endpoint or bulk writeRaw SQL
N+1 in a list viewEager loading first, raw SQL if needed
Vendor feature the ORM can’t nameRaw SQL

Raw SQL isn’t a rewrite; it’s a targeted escape hatch for the handful of queries where the generated SQL is the bottleneck. Keep the ORM as your default, profile the slow endpoint, and swap in hand-written, parameterized SQL exactly where the query plan proves it’s warranted, and nowhere else.

FAQs

Is raw SQL actually faster than an ORM?

Not inherently. A well-written ORM query and a well-written raw query hit the same query planner, so raw SQL is not automatically faster. Raw SQL wins specifically when the ORM's generated query is the bottleneck — extra round-trips, N+1 patterns, wide unbounded selects, or hot paths where set-based statements replace per-row writes. The speed advantage comes from fixing bad generated SQL, not from raw SQL itself.

What is the difference between a query builder and an ORM?

A query builder composes SQL programmatically through chainable methods while staying close to the SQL it emits; it does not map rows to objects. An ORM maps database rows to language objects and hides the SQL entirely. Knex is a query builder for JavaScript and jOOQ is a typesafe SQL DSL for the JVM — neither is an ORM. Both keep parameterization intact, so you drop the object-mapping abstraction without losing injection safety.

How do I write raw SQL without exposing my app to SQL injection?

Pass every user-supplied value through parameter placeholders and never concatenate input into the query string. In Django with psycopg the placeholder is %s, and the database driver escapes the parameters automatically; Rust's SQLx takes its placeholder from the database, so $1 in PostgreSQL but ? in MySQL, MariaDB, and SQLite. Do not add quotes around the placeholders in the SQL string. Compile-time-checked tools like SQLx verify queries against the schema before the app runs, adding a further safety layer.

Can I use raw SQL inside an ORM without removing the ORM?

Yes. Every major ORM provides an escape hatch that lets you run raw SQL while keeping the ORM as your default. Django offers connection.cursor() for direct execution, Manager.raw() to return model instances, and RawSQL for parameterized fragments inside ORM queries; SQLAlchemy exposes text(); Prisma ships TypedSQL as a preview feature plus $queryRaw for untyped access. Use the ORM for standard CRUD and reach through it to raw SQL only for the specific queries where the generated SQL is the bottleneck.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.