Typed SQL and generated SDKs instead of an ORM: does it actually work?

James Okonkwo

James Okonkwo

September 18, 2026

Typed SQL and generated SDKs instead of an ORM: does it actually work?

I dropped Hibernate on a service that had started to feel like a second product. The mappings were clever. The SQL in production was a surprise. I replaced the write path with sqlc and the scary reads with hand-written queries that CI could typecheck. It worked. It also made me miss a few things I had been taking for granted. So: does typed SQL plus a generated client actually work as a way to live without an ORM? Yes, for a class of services. No, as a religion I would apply to a Rails app that is happy.

I have now shipped sqlc in Go, used pgtyped and Kysely in TypeScript, used jOOQ when I was still in Java, and used Diesel and SQLx in Rust. I have also kept Active Record and EF Core where they were the product. Here is the split I actually use.

What I mean by “typed SQL” so we do not argue about the logo

The pattern: you write SQL (or a query builder that is still obviously SQL), a tool generates types and functions, and your app calls those functions. sqlc is the cleanest version I know — annotate a query, get a Go function that returns a struct. pgtyped does the cousin in TypeScript. Prisma is an ORM with a good query API; I am not calling Prisma “not an ORM” to win a tweet. I am talking about SQL as the source of truth, types as the output.

Generated SDKs are the other half: OpenAPI or a proto produces a client so the HTTP edge is not a pile of any. That is a separate win. I like both. I do not require both on day one.

What got better when I left the ORM

I could read the query in the PR. Reviewers who are afraid of Hibernate criteria but can read SQL started catching the bad join. That is a staffing win. The ORM had been a private language.

N+1 became a file I could grep, not a runtime personality. If the page needs lines, the SQL has a join or I write a second query I can see. I have been the person who found 400 queries behind one page. sqlc does not make you virtuous. It makes the vice visible. Visibility is most of the virtue.

Migrations became a conversation about tables again. I use golang-migrate or Flyway or a SQL file in the repo. The schema is not a surprise the ORM will “update.” I want the surprise in code review.

Performance work got shorter. EXPLAIN sits next to the query. I do not have to turn on a logger and decode a dialect. When I needed a LATERAL or a DISTINCT ON, I wrote it. I did not invent a repository method that hoped Hibernate would emit it.

Developer looking at a database query on a monitor

What I still miss

Identity map and unit of work. In a request that loads an invoice, mutates it twice, and saves, Active Record / Hibernate / EF will not insert two rows because you thought twice. With sqlc I am the unit of work. I write a transaction. I pass IDs. I make a mistake that looks like a double insert if I am sloppy. I accept that cost on services where the write paths are few and named.

Dirty tracking. “Only update the columns that changed” is free-ish with an ORM. With sqlc I write UPDATE ... SET the columns I mean, or I overwrite the row. For invoices I want the explicit SET. For a 40-column user profile CMS, I miss the ORM.

Graph loading with a sentence. include: { lines: true, customer: true } is a pleasant lie. The SQL is longer. I have teammates who will not write the longer SQL. That is a hiring and teaching problem, not a tool problem — until I am the only one who can change a report.

Validations and callbacks that live next to the model. I do not miss callbacks that send email. I miss “this column cannot be null” expressed once. I put that in the database (NOT NULL, CHECK) and in a domain function. Two places. The ORM was one place that sometimes lied about the database. I prefer two honest places to one polite liar.

Where I would still start with an ORM

A Rails or Django or Laravel product that is the company. The ORM is the culture. Replacing it is a rewrite dressed as virtue. I write the ugly query in SQL when I need it. I do not burn Active Record to feel rigorous.

A CMS-shaped admin with 30 similar CRUDs. EF Core or Active Record will ship the admin. sqlc will make me generate 30 query files that look the same. I have done that. I was not proud. I was consistent.

A team that cannot read SQL. Teach them, or keep the ORM, or do not hire me to punish them. Typed SQL is not a personality test I get to impose in week one of a Java shop that lives in Spring Data.

The hybrid I actually ship

Postgres as the system of record. Migrations in SQL. sqlc (or pgtyped, or SQLx) for the paths that are money, lists, or reports. A small domain package for the rules. A generated OpenAPI client for the frontend or for the sibling service so we do not invent JSON shapes in Slack.

I still use a query builder when the filter UI is a matrix of optional clauses. Kysely and jOOQ earn their keep there. Dynamic SQL in string concat is how you get an injection and a mess. A builder that still emits SQL you can log is the compromise.

I do not use an ORM “just for writes” and sqlc “just for reads” unless the write path is truly trivial. Two ways to talk to the same tables is a drift machine. If I hybrid, I hybrid by path — this aggregate is sqlc, that admin is ORM — not by verb.

SQL migration files next to a laptop on a desk

Does it work with generated SDKs?

The SDK is the other side of “stop inventing types.” sqlc types the database. OpenAPI types the wire. If they disagree, I want that disagreement in CI, not in a customer’s browser. I have used oapi-codegen, openapi-typescript, and buf for protos. The failure mode is a generated client that is a year stale because nobody ran the generate step. I put generate in CI and I fail the build if git is dirty. That is the whole trick. Without that trick, generated SDKs are a myth you tell in a design review.

tRPC blurs this in TypeScript monorepos: the types are the SDK. I like it when the client and server are one repo and one language. I do not like it as an excuse to skip SQL types on the server. You can have both.

Tests, fixtures, and the thing that surprised me

I thought I would miss factory_bot and a session that I could dirty. I miss them on admin apps. On the billing service I was happier with a migrated test database and a few SQL seeds. The tests got slower to write and faster to trust. They asserted on rows. They did not assert on a mock repository that implemented a fantasy.

The surprise was onboarding. A new hire who knew SQL was productive on day two. A new hire who only knew ORMs was angry on day two and fine on day ten if I sat with them. I budget that week. I do not pretend the tool is free of teaching.

A decision I would write on a whiteboard

If the service is a Go or Rust API with a handful of write paths and a lot of reports: typed SQL. If the service is a Rails product with a happy team: ORM plus SQL escapes. If the service is Java and already Spring Data: I will introduce jOOQ or JDBC for the hot query before I burn the ORM. If someone says “ORMs are always slow,” I will ask for the EXPLAIN. If someone says “raw SQL is always messy,” I will show them a 400-line Criteria query. Both can be true. The question is which mess you can review.

It actually works when the queries are the product and the team will own SQL. It fails when you wanted an identity map and a junior-friendly admin and you got a folder of .sql files nobody wants to touch. I will take the folder if the alternative is a mapping layer I cannot EXPLAIN. I will take the ORM if the alternative is me as the only person who can add a column. That is not ideology. That is who is on the bus tomorrow. I will change my mind when the team changes. I will not change it because a conference talk declared ORMs dead again.

More articles for you