What changes and what stays
If you are planning to migrate from Drizzle, you already have the hard part done: your schema lives in TypeScript, your types are inferred from it, and nothing is generated. kastor works the same way, so the move does not change how your project builds. What changes is the query surface. Drizzle gives you two APIs, a SQL shaped builder (db.select().from(users).where(eq(...))) and the relational query API (db.query.users.findMany). kastor has one object API that looks like the relational one, and covers writes, nested writes, aggregates and grouping with the same shapes.
Your database and your data stay where they are. Nothing in this guide copies rows or recreates tables. The order is always the same: get a kastor schema that matches the live database, record it as the starting point of kastor migrations, then move queries one module at a time while both libraries run side by side.
Step 1: install kastor next to Drizzle
kastor ships no driver, so you keep the one Drizzle already uses. A Postgres project keeps pg (or postgres), a SQLite project keeps better-sqlite3, a Turso project keeps @libsql/client.
npm install kastor-orm
npx kastor-orm init --db postgresinit writes kastor.config.ts, a starter schema at src/db/schema.ts, a seed file and a .env with DATABASE_URL. Point that variable at the same database drizzle.config.ts uses. If your Drizzle schema already lives in src/db/schema.ts, pick another path for kastor, for example src/db/kastor-schema.ts, in the config's schema field until the move is done.
Step 2: get a kastor schema from the database
You could translate the Drizzle schema by hand, and the mapping table below shows how. The faster way is to let kastor read the database that drizzle-kit created: db pull introspects tables, columns, defaults, indexes and foreign keys and writes a TypeScript file with every table, a schema object and a relations function guessed from the foreign keys.
npx kastor db pull --out src/db/kastor-schema.tsHere is a typical Drizzle schema and what the kastor version looks like after you tidy the export names:
import { pgTable, serial, varchar, text, boolean, integer, timestamp, pgEnum, index } from 'drizzle-orm/pg-core'
import { relations } from 'drizzle-orm'
export const role = pgEnum('role', ['user', 'admin'])
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: text('name'),
role: role('role').notNull().default('user'),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 200 }).notNull(),
published: boolean('published').notNull().default(false),
authorId: integer('author_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
}, (t) => [index('posts_author_idx').on(t.authorId)])
export const usersRelations = relations(users, ({ many }) => ({ posts: many(posts) }))
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
}))import { table, t, index, one, many } from 'kastor-orm'
export const users = table('users', {
id: t.id(),
email: t.string(255).unique(),
name: t.text().nullable(),
role: t.enum(['user', 'admin']).default('user'),
createdAt: t.datetime().map('created_at').defaultNow(),
})
export const posts = table('posts', {
id: t.id(),
title: t.string(200),
published: t.boolean().default(false),
authorId: t.int().map('author_id').references(() => users.id, { onDelete: 'cascade' }),
}, { indexes: (p) => [index(p.authorId).name('posts_author_idx')] })
export const schema = { users, posts }
export const relations = ({ users, posts }: typeof schema) => ({
users: { posts: many(posts) },
posts: { author: one(users, { fields: ['authorId'], references: ['id'] }) },
})Two habits flip. Drizzle columns are nullable unless you write .notNull(); kastor columns are required unless you write .nullable(). And the Drizzle column name argument becomes .map('db_name'), which you only need when the database name differs from the TypeScript key.
| Drizzle | kastor |
|---|---|
pgTable, mysqlTable, sqliteTable | table(), the same on every database |
serial().primaryKey() | t.id() |
varchar('x', ...), text(), integer() | t.string(len), t.text(), t.int() |
timestamp().defaultNow() | t.datetime().defaultNow() |
uuid().defaultRandom() | t.uuid().defaultRandom() |
pgEnum | t.enum([...]) |
no .notNull() | .nullable() |
index().on(...), uniqueIndex() | index(...), uniqueIndex(...) in indexes |
relations(users, ...) | relations function on the client |
typeof users.$inferSelect | InferSelect<typeof users> |
drizzle-kit generate and migrate | npx kastor migrate dev and migrate deploy |
drizzle-kit push, pull, studio | npx kastor db push, db pull, studio |
Step 3: adopt the database with a baseline
Drizzle records applied migrations in its own table (__drizzle_migrations) and journal. kastor does not read that history. It starts from a baseline instead: one migration that describes the database exactly as it is today, recorded as applied without being run.
npx kastor migrate baseline --name init
npx kastor migrate status
npx kastor migrate dev --create-only --name checkmigrate status should list the baseline as applied and nothing pending. The last command is a safety check against a copy of the database: if the pulled schema matches, kastor reports that the database is already in sync and writes no file. If it writes a file, read it, because it shows where the schema file and the database disagree. Run the baseline once per environment (development, staging, production) before the first migrate deploy there.
Note
Pick one owner for schema changes during the move. Until you remove drizzle-kit, keep generating migrations with it and run db pull again afterwards; once kastor owns migrations, stop running drizzle-kit entirely so the two never fight over the same tables.
Step 4: one client, sharing the pool
Both libraries wrap a driver instance, so you can hand them the same pool. That keeps the number of open connections unchanged while both are in use.
import pg from 'pg'
import { drizzle } from 'drizzle-orm/node-postgres'
import { kastor } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { schema, relations } from './kastor-schema'
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 })
// Old code keeps importing this until it moves.
export const drizzleDb = drizzle(pool)
export const db = kastor({ adapter: postgres(pool), schema, relations })Step 5: rewrite queries side by side
The relational API maps almost word for word. with becomes include, columns becomes select, and operator functions become keys of the where object.
import { and, desc, eq, ilike, inArray, sql } from 'drizzle-orm'
import { drizzleDb as db } from '../db'
import { posts, users } from '../db/schema'
export const feed = (authorIds: number[]) =>
db.query.posts.findMany({
where: and(eq(posts.published, true), inArray(posts.authorId, authorIds)),
with: { author: { columns: { name: true } } },
orderBy: [desc(posts.id)],
limit: 20,
})
export const search = (q: string) =>
db.select().from(users).where(ilike(users.email, `%${q}%`))
export const publish = (id: number) =>
db.update(posts).set({ published: true }).where(eq(posts.id, id)).returning()
export const countByAuthor = () =>
db.select({ authorId: posts.authorId, n: sql<number>`count(*)` }).from(posts).groupBy(posts.authorId)import { db } from '../db'
export const feed = (authorIds: number[]) =>
db.posts.findMany({
where: { published: true, authorId: { in: authorIds } },
include: { author: { select: { name: true } } },
orderBy: { id: 'desc' },
take: 20,
})
export const search = (q: string) =>
db.users.findMany({ where: { email: { contains: q, mode: 'insensitive' } } })
export const publish = (id: number) =>
db.posts.update({ where: { id }, data: { published: true } })
export const countByAuthor = () =>
db.posts.groupBy({ by: 'authorId', _count: { _all: true } })| Drizzle | kastor |
|---|---|
eq(col, v), ne | { col: v }, { col: { not: v } } |
gt, gte, lt, lte | gt, gte, lt, lte keys |
inArray, notInArray | in, notIn |
like, ilike | contains, startsWith, endsWith, with mode: 'insensitive' |
isNull, isNotNull | { col: null }, { col: { not: null } } |
and, or, not | AND, OR, NOT |
exists(subquery) | relation filters some, every, none, is |
limit, offset | take, skip, or findPage for cursors |
insert().values().returning() | create, createManyAndReturn |
onConflictDoUpdate | upsert |
db.transaction(async (tx) => ...) | db.$transaction(async (tx) => ...) |
db.execute(sql`...`) | db.$sql`...` or db.$execute`...` |
Writes with relations are where kastor saves the most code. Drizzle inserts the parent, reads the id and inserts the children inside a transaction you write yourself. kastor does that in one call:
import { db } from '../db'
export const register = (email: string) =>
db.users.create({
data: { email, posts: { create: [{ title: 'Hello' }, { title: 'Second post' }] } },
include: { posts: true },
})Raw SQL keeps working. The sql tag from kastor-orm builds fragments for where, and db.$sql<Row> runs a full statement with every interpolated value sent as a parameter.
Step 6: switch CI and remove Drizzle
Once a module has no Drizzle import left, delete its old file. When the last one goes, remove drizzle-orm, drizzle-kit and drizzle.config.ts, and replace the migrate step in your pipeline:
- run: npm ci
- run: npx tsc --noEmit
- run: npx kastor migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}Nothing else changes in the build, because neither tool generates code.
Pitfalls to check
- Nullability flips. A Drizzle column without
.notNull()is nullable. If you translate by hand, add.nullable()to every such column, or the kastor types will promise values that can be null. - Names come from the database.
db pullnames exports after tables in camelCase. If you used the Drizzlecasingoption, columns come back with.map()calls; that is correct, keep them. - Timestamps and numerics. Drizzle can return timestamps as strings with
mode: 'string'. kastor always returnsDatefort.datetime(), aYYYY-MM-DDstring fort.date()and a string fort.decimal(), so check code that formats them. - $onUpdate. For timestamps, use
t.datetime().updatedAt(), which kastor fills on every create and update. Other computed values belong in the function that writes, or in a database trigger. - Prepared statements. Drizzle's
.prepare()has no direct counterpart; the driver still caches statements where it supports that. - Two migration owners. Never run
drizzle-kit pushafter the baseline. It would change the database behind kastor's history and the nextmigrate devwould try to undo it.
The playground shows the SQL each kastor query compiles to on your database.
Questions people ask
Q1Can I keep my drizzle-kit migration history?
Keep the drizzle folder in git as a record, but let kastor start from a baseline. npx kastor migrate baseline --name init writes one migration that matches the live database and marks it applied, so migrate deploy never replays the Drizzle files. New changes after that come from npx kastor migrate dev.
Q2Does kastor have an equivalent of Drizzle relational queries?
Yes. db.query.users.findMany({ with: { posts: true } }) becomes db.users.findMany({ include: { posts: true } }). Nested includes take their own where, orderBy and take, and load in one statement per call through JSON aggregation.
Q3Can Drizzle and kastor run on the same database at the same time?
Yes. Both are thin layers over your driver. You can even pass the same pg.Pool to both, which keeps the connection count where it was while you move modules one by one.
Q4Is there a replacement for the eq, and, or helpers?
kastor uses plain objects: eq(users.email, x) is { email: x }, and(a, b) is two keys in one object or AND: [a, b], or() is OR and inArray is { in: [...] }. For anything else, the sql tag works inside where.
Q5Do I lose the SQL like builder?
kastor has no chained select builder. Its object API covers filters, relations, aggregates and grouping, and db.$sql runs any SQL with bound parameters and a typed row. If a module is a large reporting query, it is fine to leave it on Drizzle or move it to $sql.