kysely
Kysely is a SQL query builder for TypeScript. You hand it one interface that describes your database, and from then on every table name, column name, alias, join, and result row is checked at compile time and autocompleted in your editor. It is not an ORM: there are no models, no lazy relations, no change tracking, and no magic save() method. You write selectFrom, innerJoin, where, and select, and the output is SQL that looks like what you would have written by hand. It has zero runtime dependencies and talks to a database through a dialect object that wraps a driver you install yourself, so the same query code runs on Postgres, MySQL, SQLite, MSSQL, PGlite, Deno, Bun, and Cloudflare Workers. There is also a sql template tag for anything the builder cannot express, and it still participates in the type system.
If you like SQL and want TypeScript to check it, Kysely is the best option available and the type inference genuinely works, including aliases and subqueries. Budget for the parts it deliberately does not do: type generation, migration generation, and relation loading are all yours to wire up, and the 0.29 jump to ESM-only on Node 22 is a real upgrade cost.
Use it if
- You want to write SQL but get compile-time errors when a column is renamed or dropped; Kysely catches the typo in selectFrom('user') instead of at 2am in production
- You already know SQL and an ORM's abstraction is costing you more than it saves; joins, CTEs, window functions, lateral joins, and UNION are all first-class rather than escape hatches
- You are deploying to an edge or serverless runtime where Prisma's query engine binary is a problem; Kysely is plain JavaScript with no dependencies and runs on Workers, Deno, and Bun
- You need one query layer across Postgres, MySQL, SQLite, and MSSQL without rewriting queries per database, and you can accept per-dialect differences in returning clauses
- You want incremental adoption: Kysely can sit next to raw driver calls or an existing ORM because it does not own your connection or your schema
- You want your types generated from a schema file and your migrations generated from a diff. Kysely gives you neither. You either hand-write the DB interface or bolt on kysely-codegen, and migrations are hand-written up/down functions with no autogeneration
- You are on Node 18 or 20, or you ship CommonJS. Version 0.29 declares engines node >=22 and dropped the CommonJS build entirely, so require() only works on Node versions that support require(esm). Staying on 0.28 is the workaround and it is a dead end
- Your TypeScript is older than 5.4. Kysely 0.29 emits an aggressive compilation error on 5.3 and below, and the minimum has moved seven times in a single release cycle
- You have a wide schema and slow editor feedback already. The type machinery is heavy: on large DB interfaces tsserver hover and autocomplete get sluggish, which is why 0.29 shipped $pickTables and $omitTables specifically to shrink the type world of a query
- Your team wants relations loaded for them. There is no include or with-relations. Nested data means writing jsonArrayFrom and jsonObjectFrom helpers per query, plus ParseJSONResultsPlugin on dialects that return JSON as a string
- You need a stable major. After six years it is still 0.x, so every minor release can and does remove long-deprecated APIs; 0.29 alone deleted sql.value, sql.literal, ExpressionBuilder.withSchema, and moved Migrator to a separate entry point
Setup reality
npm install kysely gets you the builder and nothing else, because the driver is your problem: pg for PostgresDialect, mysql2 for MysqlDialect, better-sqlite3 for SqliteDialect, tedious plus tarn for MssqlDialect, @electric-sql/pglite for PGliteDialect. Those are not peer dependencies, so npm will not warn you; you just get a module-not-found at runtime. The bigger surprise in 0.29 is the packaging. Node 22 is the declared minimum, the CommonJS build is gone, the ESM files moved from /dist/esm/ to /dist/, and TypeScript below 5.4 gets a deliberate compilation error rather than a warning. Migration code moved too: Migrator and FileMigrationProvider now import from 'kysely/migration', and importing them from 'kysely' produces a compile-time error message telling you so. Then there is the schema type. Kysely does not read your database, so you write an interface where every table maps to a row type, auto-increment ids are Generated<number>, timestamps that you never set are ColumnType<Date, string | undefined, never>, and you derive Selectable, Insertable, and Updateable from it. Getting that wrong is the number one source of confusing errors, so most teams reach for kysely-codegen and a script that regenerates types after each migration. Finally, if you use better-sqlite3 or any native driver, you inherit its prebuild and Node ABI problems, which have nothing to do with Kysely but will eat your afternoon anyway.
Patterns
Write the schema interface Kysely checks againstdefine-database-types
import { ColumnType, Generated, Insertable, Selectable, Updateable } from 'kysely'
export interface PersonTable {
id: Generated<number>
first_name: string
last_name: string | null
created_at: ColumnType<Date, string | undefined, never>
}
export interface Database {
person: PersonTable
pet: PetTable
}
export type Person = Selectable<PersonTable>
export type NewPerson = Insertable<PersonTable>
export type PersonUpdate = Updateable<PersonTable>Generated<T> means the column is optional on insert and never required on update. ColumnType<Select, Insert, Update> with never in the update slot makes created_at unwritable. Get these wrong and the errors you see later point at the query, not at the interface.
Create a Kysely instance with a Postgres poolcreate-instance
import { Kysely, PostgresDialect } from 'kysely'
import { Pool } from 'pg'
export const db = new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
}),
}),
})
// on shutdown
await db.destroy()pg is not a dependency or a peer dependency of kysely, so nothing warns you until the import fails at runtime. db.destroy() closes the pool; skipping it keeps a serverless function alive until the platform kills it.
Select with a join and an aliasselect-join-alias
const rows = await db
.selectFrom('person')
.innerJoin('pet', 'pet.owner_id', 'person.id')
.select([
'person.id',
'person.first_name',
'pet.name as pet_name',
])
.where('person.first_name', '=', 'Jennifer')
.orderBy('person.id', 'desc')
.limit(20)
.execute()
// rows: { id: number; first_name: string; pet_name: string }[]The alias is parsed out of the string, so pet_name lands in the result type with the right type. execute() always returns an array; executeTakeFirst() returns T or undefined and executeTakeFirstOrThrow() throws NoResultError.
Build conditional and grouped WHERE clausesexpression-builder-where
const results = await db
.selectFrom('person')
.selectAll()
.where((eb) => eb.or([
eb('first_name', '=', 'Jennifer'),
eb('last_name', 'like', '%son'),
]))
.$if(minAge !== undefined, (qb) => qb.where('age', '>=', minAge!))
.execute()Chained .where() calls are AND. Use the expression builder callback for OR and for nesting. $if keeps the builder chainable for optional filters instead of reassigning a mutable query variable, but note that it cannot change the result type.
Insert rows and read back generated columnsinsert-returning
const inserted = await db
.insertInto('person')
.values({ first_name: 'Jennifer', last_name: 'Aniston' })
.returning(['id', 'created_at'])
.executeTakeFirstOrThrow()
// bulk insert
await db
.insertInto('person')
.values([{ first_name: 'a' }, { first_name: 'b' }])
.execute()returning() works on Postgres and SQLite; MySQL has no RETURNING, so there you read insertResult.insertId from the InsertResult that execute() gives back. That is the most common place per-dialect portability breaks.
Upsert with ON CONFLICTupsert-on-conflict
await db
.insertInto('person')
.values({ id: 1, first_name: 'Jennifer' })
.onConflict((oc) => oc
.column('id')
.doUpdateSet((eb) => ({
first_name: eb.ref('excluded.first_name'),
}))
)
.execute()eb.ref('excluded.first_name') is how you reach the row that was being inserted. On MySQL the equivalent is .onDuplicateKeyUpdate(), so this block does not port across dialects unchanged.
Run several statements in one transactiontransaction
const result = await db.transaction().execute(async (trx) => {
const person = await trx
.insertInto('person')
.values({ first_name: 'Jennifer' })
.returning('id')
.executeTakeFirstOrThrow()
await trx
.insertInto('pet')
.values({ owner_id: person.id, name: 'Catto' })
.execute()
return person
})Returning normally commits, throwing rolls back. You must use trx, not db, inside the callback; a stray db call runs on a different connection outside the transaction and will not be rolled back. Set isolation with db.transaction().setIsolationLevel('serializable').
Load related rows as nested JSON in one querynested-json-relations
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'
const people = await db
.selectFrom('person')
.selectAll('person')
.select((eb) => [
jsonArrayFrom(
eb.selectFrom('pet')
.select(['pet.id', 'pet.name'])
.whereRef('pet.owner_id', '=', 'person.id')
).as('pets'),
jsonObjectFrom(
eb.selectFrom('address')
.select('address.city')
.whereRef('address.person_id', '=', 'person.id')
).as('address'),
])
.execute()This is the closest thing to relation loading Kysely has, and you import it per dialect from kysely/helpers/postgres, /mysql, /sqlite, or /mssql. On SQLite and MySQL the JSON comes back as a string until you add ParseJSONResultsPlugin.
Drop to raw SQL without losing typesraw-sql-tag
import { sql } from 'kysely'
const rows = await db
.selectFrom('person')
.select([
'id',
sql<string>`concat(first_name, ' ', last_name)`.as('full_name'),
])
.where(sql<boolean>`age between ${18} and ${65}`)
.execute()
const raw = await sql<{ count: number }>`select count(*) as count from person`.execute(db)Interpolated values become bound parameters. Identifiers must go through sql.ref() or sql.table(), and sql.lit() inlines a literal; sql.value and sql.literal were removed in 0.29 in favour of sql.val and sql.lit. The type parameter is an assertion, so a wrong one silently lies to you.
Write and run migrationsmigrations
// migrations/2026-08-01-add-person.ts
import { Kysely, sql } from 'kysely'
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('person')
.addColumn('id', 'serial', (c) => c.primaryKey())
.addColumn('first_name', 'varchar(255)', (c) => c.notNull())
.addColumn('created_at', 'timestamptz', (c) => c.defaultTo(sql`now()`))
.execute()
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('person').execute()
}Migrations are hand-written; nothing diffs your schema for you. Type them as Kysely<any>, not Kysely<Database>, or an old migration stops compiling the moment you change the current schema interface.
Drive the migrator from a scriptrun-migrator
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { FileMigrationProvider, Migrator } from 'kysely/migration'
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(process.cwd(), 'migrations'),
}),
})
const { error, results } = await migrator.migrateToLatest()
results?.forEach((r) => console.log(r.status, r.migrationName))
if (error) { console.error(error); process.exit(1) }
await db.destroy()In 0.29 these moved out of the root export: importing Migrator from 'kysely' now fails at compile time with a message pointing at 'kysely/migration'. migrateToLatest resolves with an error field instead of throwing, so a script that ignores it exits 0 on a failed migration.
Use camelCase in code and snake_case in the databaseplugins-camel-case
import { CamelCasePlugin, Kysely, ParseJSONResultsPlugin } from 'kysely'
const db = new Kysely<Database>({
dialect,
plugins: [new CamelCasePlugin(), new ParseJSONResultsPlugin()],
})
// interface now uses firstName, query emits "first_name"The plugin rewrites identifiers both ways, so your Database interface must be written in camelCase and your migrations still in snake_case. It cannot see inside sql`` fragments, so raw SQL keeps the database naming and quietly breaks the illusion.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| drizzle-orm | npm | You want the same SQL-first feel plus a schema defined in TypeScript, generated migrations, and a relational query API for nested reads |
| prisma | npm | You want generated types, generated migrations, and relation loading out of the box and can live with a heavier client and a schema DSL |
| knex | npm | You need CommonJS, older Node, or a query builder whose migration and seeding CLI is already wired into your deploy scripts |
| kysely-codegen | npm | You are keeping Kysely but want the DB interface generated from your live database instead of maintained by hand |