# 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://replicas.dev/blog/why-we-switched-from-sql-to-an-orm

I have never liked ORMs.

ORMs are libraries that map database rows to objects in your application. Instead of writing SQL, you call something like `user.findMany({ where: { orgId } })`. The ORM builds the query, runs it, and gives you typed objects back.

The concept seems smart. In practice, the abstraction creates bad tradeoffs.

Knowing Postgres stops helping because now you have to learn how this particular ORM will work. The query is built at runtime, so when a network call is slow you are debugging the ORM’s interpretation of SQL instead of SQL you wrote. Fetching a list with related data can also turn into 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.

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. For a long time, writing SQL myself was an easy trade to make.

## The downsides of raw SQL

Raw SQL gave us complete control, but almost no guardrails. TypeScript could not inspect the selected columns, validate the return type, or tell us when two copies of the same filter had drifted apart.

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 claim. 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, so they drifted.

There were many queries like this across the monolith.

Knowing whether one of these queries was correct came down to someone reading it carefully. That had worked for us for a long time. It was starting to feel pretty stupid.

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 or whether the code feels nice to write.

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.

## Then I found the perfect solution

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 selected instead of a type assertion 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.

## How we migrated

We moved the entire monolith from pg-promise to Kysely 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.

We switched to something close enough to an ORM that the title works. Kysely avoids almost everything I hated about ORMs in the first place.

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

