mrkeyoor.com_
Thu 06 Aug 10:56 UTC
npmDataupdated 06 Aug 2026

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.

Verdict

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.

API stability3/5The query building surface has been steady for years and most 0.26 query code still compiles, but the project is still 0.x, minor releases delete long-deprecated APIs, and 0.29 alone dropped CommonJS, raised the Node floor to 22, raised the TypeScript floor to 5.4, and moved Migrator to a new entry point.
Docs4/5kysely.dev has a dialect-aware getting-started, a large recipes section, and a runnable playground, plus full TSDoc on every method that shows up on hover in the editor; what is missing is guidance on scaling the type layer and honest coverage of per-dialect behaviour differences.
Maintenance4/5Pushed 5 August 2026 with 0.29.4 released 17 July 2026 and a 0.30 beta already out; releases are frequent and changelogs are detailed, though 141 open issues (171 counting PRs) sit against what is effectively two active maintainers.
Ecosystem4/5Around 13.5M weekly downloads with community dialects for Neon, PlanetScale, D1, Turso, and Durable Objects, plus kysely-codegen and prisma-kysely for types; it is smaller than the Prisma and Drizzle ecosystems and most add-ons are single-maintainer projects.

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
Skip it if

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

PackageRegistryPick it when
drizzle-ormnpmYou want the same SQL-first feel plus a schema defined in TypeScript, generated migrations, and a relational query API for nested reads
prismanpmYou want generated types, generated migrations, and relation loading out of the box and can live with a heavier client and a schema DSL
knexnpmYou need CommonJS, older Node, or a query builder whose migration and seeding CLI is already wired into your deploy scripts
kysely-codegennpmYou are keeping Kysely but want the DB interface generated from your live database instead of maintained by hand