Back to blog
February 14, 2026

I stopped using ORMs

I haven't used an ORM in years. Every time I try one again, I remember why.

BY MANYROWS TEAM ·
engineering databases

I haven’t used an ORM in years. Not because I read a blog post that convinced me. Because every ORM I’ve used eventually got in the way at the worst possible time.

The pitch

ORMs sell you a good story. Don’t write SQL. Don’t think about tables. Just work with objects. Your code stays clean, your database stays abstract, and you can swap Postgres for MySQL whenever you want (you won’t).

And for the first week, it works. You define a model, call .save(), and a row appears. Magic.

When it stops working

Then you need a query the ORM doesn’t want to write.

Maybe it’s a join across three tables with a conditional filter. Maybe it’s an upsert with a returning clause. Maybe it’s a subquery. The SQL is five lines. The ORM version is thirty lines of method chaining that generates SQL you wouldn’t have written, and you’re reading the ORM’s documentation more than your own code.

So you drop down to raw SQL “just for this one query.” Then another. Then you have half your queries in the ORM and half in raw SQL and two mental models for the same database.

The migration problem

ORM migrations are the part nobody warns you about.

Auto-generated migrations look at your models and diff them against the database. This works until it doesn’t, and when it doesn’t, it drops a column in production because you renamed a field.

I’ve seen teams spend entire days debugging a migration that an ORM generated. The fix was always the same: write the migration by hand. At which point, what is the ORM doing for you?

What I actually do

I write SQL. That’s it.

SELECT id, email, name FROM users WHERE workspace_id = $1 AND role = $2
var user User
err := pool.QueryRow(ctx,
    "SELECT id, email, name FROM users WHERE workspace_id = $1 AND role = $2",
    workspaceID, role,
).Scan(&user.ID, &user.Email, &user.Name)

The query is right there. I can copy it into psql and run it. I can EXPLAIN it. I can see exactly what indexes it hits. When it’s slow, I know where to look.

Migrations are just SQL files, applied in order. I write them by hand. They do exactly what I tell them to, nothing more.

The boring parts

Yes, you write more boilerplate. Scanning rows into structs isn’t exciting. Writing INSERT INTO statements by hand isn’t glamorous.

But I’ve never spent an afternoon debugging my INSERT statement. I’ve spent plenty of afternoons debugging an ORM’s.

AI makes this even easier

The main argument for ORMs was always reducing boilerplate. AI has made that argument irrelevant.

Define your tables and columns in one place, your migration files. An AI assistant can see the schema, see your existing query patterns, and write the next INSERT or SELECT with the correct column names, parameter placeholders, and struct scanning. The boilerplate that used to be tedious takes seconds.

The difference is that the generated code is just SQL. You can read it, run it, debug it. When an ORM generates a query, you get an abstraction you have to reverse-engineer. When AI writes a query, you get the same SQL you would have written yourself, just faster.

”But what about type safety?”

Fair question. Tools like sqlc generate type-safe Go code from SQL queries. You write the SQL, it generates the boilerplate. Best of both worlds if the boilerplate bothers you.

I don’t use it, I don’t mind the boilerplate, but it’s a reasonable middle ground.

The real reason

The actual reason I stopped using ORMs isn’t technical. It’s that SQL is a better language for talking to databases than whatever DSL an ORM invented to avoid it.

SQL has been around for 50 years. It’s not going anywhere. Every developer you hire knows it or should. Every database tool speaks it natively. Every performance guide is written in terms of it.

An ORM is a translation layer between you and your database. I’d rather just talk to my database directly.


More from the blog