ORMs are genuinely useful for 80% of your queries. The simple stuff — find by ID, insert a row, update a field — they handle well. Where they fall apart is the 20% that actually matters: the complex joins, the window functions, the CTEs, the queries that need to be fast.
The illusion of portability
The pitch is usually: "Write once, run on any database." In practice, I've never once switched databases mid-project. The portability is theoretical. What you actually get is a layer of abstraction over SQL that leaks in unexpected ways.
When you need to express something your ORM doesn't support natively, you end up writing raw SQL anyway — but now it's embedded in a string inside an ORM call, with none of the type safety or autocomplete you were promised.
Where I draw the line
Simple CRUD: ORM. Anything with multiple joins, aggregations, or performance requirements: raw SQL with a typed query builder or just pg directly.
The tools I reach for now:
- Go:
sqlc— generates type-safe Go code from SQL queries. Write SQL, get types. - Node:
postgres(porsager) orkyselyfor when I want builder ergonomics without losing the SQL.
The actual tradeoff
Raw SQL is more verbose. You write the query, you write the type, you maintain both. That's real overhead.
But the query is readable. You can paste it into your database client and run it. The execution plan is predictable. You're not debugging what the ORM generated at 2am when prod is slow.
For a personal project or a prototype, use whatever. For anything that sees real load, know your SQL.