My Case Against ORMs
Published on May 27, 2025
Let me just say it straight: I don’t like using ORMs.
People act like using an ORM is some sort of higher-level abstraction that makes life easier. In reality, it hides the actual thing you care about—your SQL—and replaces it with a Rube Goldberg machine of struct tags, magic method chaining, and runtime query generation. And then they call it “maintainable.” It’s a trap. A convenient-looking trap.
The “One Model to Rule Them All” Fallacy
On paper, having a single model seems like a good idea. But once you get into any real-world scenario, it becomes a mess. Suddenly, the field that makes sense for the DB (sql.NullString or a custom nullable type) becomes a pain in the API layer. And if you want to omit that field in some response? Tough luck. You’re stuck with a struct that’s trying to do five jobs at once. It’s the god function problem, but with data.
When you split your models one for DB, one for API suddenly your logic gets cleaner. You don’t worry about some random tag breaking your DB logic. You can shape your API payloads exactly how they should be, and your DB models can focus on what the database actually wants.
“But What About SQL Injection?”
A common argument in favor of ORMs is that they protect you from SQL injection.
That’s only true if you're writing raw SQL naively by string-concatenating user input directly into your queries. But nobody doing raw SQL seriously does that.
The actual solution is simple and safe: use prepared statements.
All modern database drivers support them. Whether you're using Go, Node.js, Python, or Rust, parameterized queries are easy and idiomatic. You pass values as arguments, and the driver handles escaping and query planning safely.
No ORM magic required. No injection risk.
ORMs Make Easy Things Easy and Hard Things Impossible
Yes, ORMs can make the basics easier. User.find(...), .save(), .delete() fine. But try writing a query that actually touches real business logic: something with joins, aggregates, filters, maybe window functions. Now your nice ORM abstraction becomes a maze of nested includes, prefetches, or custom query builders that fight the underlying database every step of the way.
Ever debugged an n+1 query problem in an ORM? Good luck. Prefetching in ORMs is usually painful and opaque. With raw SQL, you just write the join.
Runtime Queries Are a Performance Red Flag
Most ORMs build and compile your queries at runtime, based on struct tags and method chaining. That means every query is dynamically assembled when the code runs. You don’t get to see the final query easily. You don’t know what exactly is sent to the database unless you enable logging and reverse-engineer it.
Why?
You’re using a straightforward language like SQL, but then instead of writing that directly, you wrap it in a JavaScript-style query builder or some DSL. And now you have to trust that it’s doing the right thing.
Someone might argue: But manually writing SQL and mapping table fields to code wastes time, what about type inference, syncing column names, etc.?
Fair. That would be painful if you had to do it all by hand.
But you don’t. That’s where tools like sqlc shine.
You write your SQL. It parses it and auto-generates type-safe Go code from it. No need to manually map types. It’s fast, ergonomic, and works with lightweight drivers. You get static analysis and full compiler support—with zero runtime reflection or dynamic DSLs.
The Bottom Line
If you care about:
- Performance
- Debuggability
- Query clarity
- Explicitness
- Clean separation of concerns
then you shouldn’t be using an ORM.
Use a tool like sqlc that gives you the best of both worlds: real SQL + generated types. It’s fast, safe, and transparent. And most importantly, it respects your database instead of pretending it’s a Java object store.
Inspired by Jeff Atwood