All writing02 / 10
Engineering Mar 20, 2025 13 min read

The unreasonable effectiveness of plain SQL

ORMs are fine. Until they aren't. After a decade of building data layers on every framework we could find, we keep coming back to plain SQL — and the patterns that make it pleasant.

Hiroshi Tanaka
Co-founder · CTO
Share
Engineering
13 min read
Issue · 2025

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.


Written by
Hiroshi Tanaka
Co-founder · CTO · Igknight Tech
Found this useful?
Share

Want this kind of work on your project?

One conversation, honest pricing, recommended engagement model — within a business day.

Start a project