Essay · Backend

Why I stopped using ORMs for complex queries

Joy Dey May 02, 2026 8 min read Laravel PostgreSQL Backend

I've been writing software professionally for a few years now, and one thing I keep coming back to is the tension between developer convenience and query correctness. ORMs — Object-Relational Mappers — promise both. In practice, for anything beyond basic CRUD, they deliver neither.

The promise of ORMs

When you first encounter an ORM like Laravel's Eloquent, Sequelize, or SQLAlchemy, the pitch is compelling. You write Python or PHP objects, and the ORM translates them into SQL. No SQL knowledge required. Readable, chainable query builders. Automatic relationship loading. It all sounds great.

And for simple applications — a blog, an admin panel with straightforward reports — it genuinely is great. User::where('active', true)->get() is clean and obvious. I'm not here to argue you should never use an ORM.

Where it breaks down

The problems start when your data requirements grow. Aggregations across multiple joined tables. Conditional groupings. Window functions. Recursive CTEs. The moment you need SQL that looks like this:

WITH monthly_revenue AS (
  SELECT
    DATE_TRUNC('month', created_at) AS month,
    SUM(total) AS revenue,
    COUNT(*)   AS order_count
  FROM orders
  WHERE status = 'completed'
  GROUP BY 1
)
SELECT
  month,
  revenue,
  revenue - LAG(revenue) OVER (ORDER BY month) AS delta,
  ROUND(
    100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
    / NULLIF(LAG(revenue) OVER (ORDER BY month), 0),
  2) AS pct_change
FROM monthly_revenue
ORDER BY month DESC;

…you will find yourself in ORM hell. The abstraction starts leaking. You end up calling DB::raw() so often that the ORM is doing nothing but adding indirection. Worse, the generated SQL is often subtly wrong — extra subqueries, missing index hints, inefficient join ordering — and you won't notice until production traffic reveals the N+1 you missed.

The silent N+1

N+1 queries are the ORM's original sin. You load a list of orders and then loop over them to access each order's customer. The ORM issues one query for the orders, then N queries for the customers — one per row. With eager loading (with('customer')) you can avoid it, but it requires discipline at every call site.

"An ORM makes the easy things easy and the hard things invisible — until they bite you in production."

I've had to debug production slowdowns caused by N+1 queries buried three layers deep in a service class, invisible to any code review. Raw SQL makes the data flow explicit. What you write is what hits the database.

What I do now

I still use ORMs for simple lookups and writes. For anything involving aggregation, reporting, or multi-table joins, I reach for raw SQL — usually wrapped in a well-named repository method:

// OrderRepository.php
public function monthlyRevenueTrend(int $months = 12): Collection
{
    return DB::select(<<= NOW() - INTERVAL ':months months'
            GROUP BY 1
        )
        SELECT month,
               revenue,
               LAG(revenue) OVER (ORDER BY month) AS prev_revenue
        FROM monthly
        ORDER BY month DESC
    SQL, ['months' => $months]);
}

The SQL is readable, testable, and — crucially — explicit. The next developer (or future me) knows exactly what the query does without tracing through five layers of query builder chains.

Practical tips

  • Use your ORM for writes and simple reads — inserts, updates, deletes, single-table selects.
  • Use raw SQL in repository methods for reports, analytics, and complex joins.
  • Always EXPLAIN ANALYZE your queries before shipping to production.
  • Add query logging in development — you'll be surprised how many queries fire per request.
  • Consider read replicas for heavy analytical queries — don't let them compete with writes.

Conclusion

ORMs are great tools. They're not the right tool for every job. When your queries grow complex, fight the urge to bend the ORM to your will. Write the SQL directly. Your database will thank you, and so will the next engineer who inherits your codebase.

If you found this useful or want to push back on any of this, I'd love to hear from you — my email is always open.