From Knex to Drizzle ORM: what nobody tells you about the migration
An honest retrospective on migrating a production multi-tenant SaaS from Knex query builder to Drizzle ORM - including the sharp edges of TypeScript, ESM and the runtime decisions you'll have to make along the way.
Recently I converted Salonnare's server backend from Knex query builder (CommonJS, JavaScript) to Drizzle ORM (ESM, TypeScript). 88 tables, around thirty route files, 104 migrations. Drizzle's sales pitch is mostly accurate, but there's a layer of small pitfalls you only notice once you actually do the migration. Here's what I wish I'd known before I started.
Why migrate at all?
Knex does its job well, and twelve years on it's still a fine query builder. Two things just got more painful every month:
- No end-to-end types. Knex doesn't know what
select('id', 'name')returns. Every route had hand-typed interfaces, and on schema changes the bugs piled up until you hit production. - CommonJS held us back. Many modern libraries (
next-intl,node-fetchv3, recent OpenAI SDK) are ESM-only. We needed more and more hoops to drag those into our CJS process.
Drizzle solves both: bookings.$inferSelect gives you the exact row type, and the whole toolchain is ESM-native.
Pitfall 1: Drizzle introspect crashes on large schemas
Drizzle advertises drizzle-kit introspect to scan an existing database and generate TS schema files. On a schema of 88 tables with enums, foreign keys and composite indices, introspect crashed for us spectacularly - different errors on different runs, sometimes a silent crash.
What did work: writing them by hand, table by table, grouped in shared/src/schema/<name>.ts with a barrel export via shared/src/schema/index.ts. Boring, but every table got accurate typing and relations from the start. In hindsight this is the right investment, since a generated file would have needed full review everywhere anyway.
Pitfall 2: .where() is mandatory in Drizzle (and that's a feature)
In Knex db('bookings').delete() does a full table delete if you forget .where(). In Drizzle, TypeScript refuses to compile the same call without a where clause. Small difference, but in a multi-tenant SaaS this is the difference between a quiet Tuesday and an incident report.
// Compiles: TS forces you to provide a where clause
await req.db.delete(bookings).where(eq(bookings.id, bookingId));
Pitfall 3: ESM imports require .js extensions in TS source
The first week's biggest annoyance. With "module": "NodeNext" in tsconfig.json, internal imports in TypeScript source must explicitly carry the .js extension - even though the file is named foo.ts.
// Doesn't work
import { auth } from '../middleware/auth';
// Works
import { auth } from '../middleware/auth.js';
For a large codebase this means a few hours of regex find-and-replace. Make a separate commit for the import rewrites, distinct from the Drizzle migration - otherwise your diff is unreadable during code review.
Pitfall 4: Building a tenant-scoped wrapper around Drizzle
Drizzle is low-level: you get a query builder, not an ORM with automatic scopes. For multi-tenant SaaS that's a problem, because one forgotten tenant_id filter is a data leak. My solution was a wrapper around the Drizzle client that automatically injects the tenant filter on every select(), insert(), update() and delete():
const rows = await req.db.select().from(bookings).where(eq(bookings.status, 'scheduled'));
// The wrapper invisibly adds AND bookings.tenant_id = ?
19 unit tests cover the wrapper's behaviour. That's the level of paranoia you need before trusting this layer, because it is literally the wall between customers.
Pitfall 5: Production runtime - tsc or tsx?
The official recommendation is tsc to compile your TypeScript to JavaScript and run that output. In practice this gave endless small ESM resolution issues around Drizzle's own modules and the @salonnare/shared workspace package.
What did work: running tsx src/index.ts directly in production. No separate build step, type-check stays as our CI gate (tsc --noEmit). Slightly more memory, faster deploy, same Docker image structure. For Salonnare's scale a fine trade-off.
Pitfall 6: Existing tests had to be rewritten
Our test suite used Knex mocks. Those mocks are fundamentally incompatible with Drizzle's chained builder API. Instead of porting all 200+ tests at once, I did this:
- New tests for the tenant wrapper (19) - first priority, this layer protects everyone
- Existing route tests
@ts-nocheck+describe.skip(...)- present, but not running - TODO in commit message: rewrite per route at the next change touching that route
This is technical debt, but it isolated the Drizzle migration to a defined project. Rewriting tests in parallel with query rewriting makes the blast radius unmanageable.
What I would do differently
If I had to start over tomorrow:
- Do the import rewrites in a separate PR. No Drizzle code, just adding
.jsextensions. Reviewers can then focus on the Drizzle PR with a clear head. - Build your tenant wrapper before migrating your first route. Otherwise you'll guaranteed miss one route that leans on the old pre-tenant patterns.
- Write the schema files first. Don't try introspect. Table-by-table by hand, and pick your entity types via
$inferSelect/$inferInsertwhile you're there. - Migrate routes per domain, not per file. Bookings → clients → invoices, instead of alphabetically. Domain coherence helps you rewrite tests intelligently.
A few weeks later, Drizzle still feels like the right call. End-to-end types are genuinely valuable, ESM has unlocked the door to modern libraries, and the wrapper layer has finally moved tenant isolation into code instead of into code review comments. The migration took roughly a week of effective time - spread over two calendar weeks, because I wasn't going to gamble with production.
Better to know all the pitfalls in advance than to hit each one in turn.
