We've shipped projects on Rails Active Record, Django ORM, SQLAlchemy, Prisma, Drizzle, TypeORM, Sequelize, and a few I've already forgotten. Every one of them was lovely for the first six months. Every one of them, by year two, was the part of the codebase nobody wanted to touch.
Today we default to plain SQL with a thin migration layer and a small set of conventions. This isn't an ORM-bashing post — ORMs are real engineering that solve real problems. It's a post about what we kept learning the hard way and finally decided to stop relearning.
Where ORMs go wrong at scale
ORMs optimize for the common case: a single table, a single record, written and read by a single language runtime. They get worse at every step away from that. Joins across more than two tables become awkward. Aggregations require escaping into raw SQL anyway. Performance debugging requires reading both your code and the ORM's generated query, which is twice the cognitive cost of just reading SQL.
The break is rarely sudden. It's a slow accumulation of small workarounds. A `.raw()` here. A view added in a migration to hide a complex query. A custom finder that bypasses the ORM cache. By year three you have an ORM-shaped wrapper around what is, functionally, plain SQL — but without the legibility.
The pattern we settled on
Plain SQL queries live in `.sql` files next to the module that uses them. A tiny runtime function reads the file, substitutes parameters safely, and runs it. Type information for the result is generated from the database schema once per build — we use `pg-typed` for Postgres, but any schema-aware codegen works. The application code calls a typed function. The SQL is the file.
The result is a query layer that's visible at the file system level. You can `grep` for any column name and find every place it's referenced. You can read a query without leaving your editor. New engineers don't have to learn the ORM's particular dialect, only SQL — which they'll need anyway.
Migrations are non-negotiable
Plain SQL doesn't mean plain everything. Migrations need real tooling — versioned, ordered, with up and down paths, run automatically in CI against a real Postgres. We use `node-pg-migrate` for Node projects and `goose` for Go ones. Both work the way you'd hope. The migration tool is the one place where a small abstraction earns its keep, because schema drift is the bug class that ruins quarters.
We also commit to a rule: every migration must be reviewable as a standalone document. If you can't understand what a migration does by reading just the migration, it's too big. Small migrations land daily. Schema changes never block deploys.
What you give up
You lose the ability to swap databases trivially. We've never used this ability in production, but plenty of teams do. You lose the ability to lazily load relationships — but you also lose the bugs that come from accidental lazy loading. You lose some of the comfort of an active community of ORM users with the same problems as you. In return you get queries that are exactly what you wrote, performance that's exactly what your database can do, and a layer that ages well.
We don't recommend plain SQL for everyone. Small teams with simple data, junior teams without a senior database engineer, projects with genuinely uncertain schema — ORMs probably win. For everything else, give plain SQL a fair shake. You may surprise yourself.
Want this kind of work on your project?
One conversation, honest pricing, recommended engagement model — within a business day.
Keep reading
All articlesHow we estimate software projects (and why most estimates are wrong)
Every estimate is wrong. The useful ones are wrong in known ways. A practical method we've refined across 50+ engagements — and the failure modes we learned to spot first.
Why we don't write microservices anymore
We split into microservices because it was 2019. Then we spent three years feeling the cost. Here's what we learned, and how we structure backends in 2025.
Why your first ML model should be embarrassingly simple
The first model in production teaches you more in a week than a perfect model in a notebook teaches you in a quarter. A practical case for shipping something terrible — on purpose — first.