# Why we switched from SQL to an ORM (kinda)

I still dislike ORMs. But moving our monolith to Kysely gave us typed queries without taking SQL away.

- Author: Saai Arora
- Published: 2026-07-28
- Category: Engineering
- Canonical: https://tryreplicas.com/blog/why-we-switched-from-sql-to-an-orm

A few weeks ago, I was comparing tech stacks with Shams, my roommate, who works at Composio. He mentioned that his team uses an ORM.

My immediate reaction was basically “absolutely not.”

I have never liked ORMs. I like writing SQL, I like knowing exactly which query runs, and I do not want to learn a library’s strange version of SQL just to talk to Postgres.

The conversation did leave me with one annoying fact. Our answer to “how do you know this raw query is correct?” was that someone read it carefully.

That answer had worked for us for a long time. It was starting to feel pretty stupid.

## Why I avoided ORMs

An ORM maps your tables to objects in your language. You call something like `user.findMany({ where: { orgId } })`, it builds the SQL, runs it, and gives you typed objects back.

The pitch is nice. The abstraction is what I dislike.

Knowing Postgres well stops helping because now you have to learn how this particular ORM thinks. The actual query gets assembled at runtime, so when something is slow you end up debugging SQL that appeared out of thin air. Asking for a list and something related to every item can quietly become one query per item.

Your schema often exists twice: once in the ORM and once in the database. The ORM adds a large dependency, sometimes another binary, and usually a generated client that needs to be rebuilt every time the schema changes.

The escape hatch is the worst part. Every ORM has a raw query function for the queries it cannot express. The moment you use it, you are back to a plain string with no type checking. Your hardest queries, the ones most likely to be wrong, get the least help.

Writing SQL myself was an easy trade to make.

## Raw SQL was starting to hurt

Our workspace list query used to look like this:

```ts
params.push(limit);
paginationClause = ` LIMIT $${params.length}`;

const sql = `SELECT ${getWorkspaceRecordSelect('w')}${selectExtra}
   FROM ${WORKSPACES_TABLE} w${joinClause}
   WHERE ${whereClauses.join(' AND ')}
   ORDER BY COALESCE(w.last_activity_at, w.created_at) DESC${paginationClause}`;

const rows = await db.manyOrNone<WorkspaceRecord>(sql, params);
```

The `<WorkspaceRecord>` on the last line is basically me telling TypeScript, “trust me, this is what Postgres will return.” Nothing checks that it is true. The selected columns come from another function that returns a string, so TypeScript never sees them.

Rename a column and this still compiles. A user gets to find the bug for you.

We also had one query for the workspace list and another to count the same rows for pagination. Both repeated the same conditions as strings. They drifted, obviously.

There were hundreds of queries like this across the monolith. We had complete control over the SQL, but almost no guardrails around it.

Then agents started writing more of those queries.

## Agents made types more valuable

Models are genuinely good at SQL. A lot of the queries in our codebase were written by an agent, and the SQL itself is usually right.

For about five seconds, this made type safety seem less important. The thing writing the query does not care about autocomplete and does not need the code to feel nice to read.

But agents are at their best when they get fast feedback. An agent writing raw SQL usually finds out it was wrong when the query runs. An agent writing typed code finds out while it is still working and can fix the mistake in the same turn.

Every agent that touches our codebase runs `tsc`. It is by far the cheapest reviewer we have. It runs on every change, never gets tired, and does not care how large the diff is. Raw SQL was completely invisible to it.

I later found research making the same point. One paper found that constraining a model to write well-typed code [cut compilation errors by more than half and improved functional correctness](https://arxiv.org/abs/2504.09246). Another found that feeding static analysis output back into the loop [drove error rates down quickly over a few iterations](https://arxiv.org/pdf/2412.14841).

More code gets written now than any human on our team will read line by line. Leaving every SQL query outside the feedback loop stopped making sense.

## Kysely is the part I wanted

I went looking for something that could type-check our SQL without hiding it and found Kysely.

Kysely calls itself a query builder, not an ORM. In practice, it feels like writing SQL in TypeScript.

```ts
const rows = await kyselyDb
  .selectFrom('workspaces as w')
  .select(workspaceRecordColumns)
  .where((eb) => workspaceListFilter(eb, options))
  .orderBy(sql`COALESCE(w.last_activity_at, w.created_at)`, 'desc')
  .limit(limit)
  .execute();
```

It reads from top to bottom like the query it produces. There are no objects mapped to tables, no lazy loading, and no surprise queries fired behind your back.

Our schema stays in plain SQL migration files. We generate types from the real database, so selecting a column that does not exist fails to compile. The row type comes from the columns you actually selected instead of a type assertion you wrote at the bottom.

This was the deal I wanted all along. I could keep writing the query myself and let TypeScript check it.

Even the escape hatch is reasonable. Raw fragments go through a `sql` template tag, and every interpolation becomes a bound parameter.

## Nine PRs later

We moved the entire monolith over in nine pull requests across eight days. The last PR alone touched around 180 call sites in 41 files.

What surprised me most was how much code disappeared.

We deleted an entire category of functions whose only job was to return pieces of SQL as strings: `getWorkspaceRecordSelect`, `buildViewModeSqlClauses`, `ownershipWhereClause`, and a lot more. The workspace list and count queries now share one filter, so they cannot disagree about which rows exist.

One important asterisk: Kysely does not catch everything. It will happily let you hand Postgres a JS array where a `jsonb` column expects an object. We did exactly that, and a local smoke run caught it. Types are a feedback loop, not proof that the code works.

So did we switch to an ORM? Kinda. Kysely is close enough that the title works, but it avoids almost everything I hated about ORMs in the first place.

I still write the SQL. TypeScript finally gets to read it too.

