After three years of fighting my ORM on anything beyond simple CRUD, I finally gave up and went raw SQL for all analytical queries. Here’s what I learned.

The problem

ORMs are great at what they’re designed for: mapping rows to objects, handling simple CRUD, and abstracting away database differences. But the moment you need a window function, a lateral join, or a complex aggregation, you’re fighting the tool.

SELECT
  user_id,
  event_type,
  COUNT(*) OVER (PARTITION BY user_id ORDER BY created_at) as running_total
FROM events
WHERE created_at > NOW() - INTERVAL '30 days'

Try expressing that cleanly in most ORMs. You can’t — not without dropping to raw SQL anyway, at which point you’ve lost the benefit.

What I switched to

Raw SQL with a thin query builder for the dynamic parts. The key insight: the abstraction you actually need is parameterized queries, not object mapping.

What I kept the ORM for

  • Simple CRUD on known entities
  • Schema migrations (the ORM’s migration tools are still excellent)
  • Seeding test data

The rest is plain SQL, wrapped in typed functions that validate the output shape.