A developer's desk showing type-safe query code, with a large red Maine Coon cat resting on a side shelf.

Kysely: SQL With Types That Actually Have Your Back

The Orange Cat
The Orange Cat

There is a particular kind of frustration that every TypeScript backend developer eventually meets: you want type safety for your database access, but you do not want an ORM standing between you and your SQL. Heavy ORMs give you lovely autocompletion at the cost of abstraction, codegen steps, and the occasional baffling query they generate on your behalf. Raw SQL and older builders like Knex give you total control but treat every column name as a plain string and every result as any. Kysely (pronounced "Key-Seh-Lee") plants its flag squarely in the middle of that spectrum.

Kysely is a type-safe SQL query builder for TypeScript. You describe your database schema once as a set of TypeScript interfaces, and from then on the builder only lets you reference tables and columns that truly exist, autocompletes names and operators, and — the headline trick — infers the exact result row type from precisely the columns you selected. It is explicitly inspired by Knex.js (Knex's own creator, Tim Griesser, even contributes to it), but every typing is designed-in rather than bolted on. It is not an ORM: there are no models, no lazy-loaded relations, no object graph. You write queries that map almost one-to-one to SQL, and you get types back.

Why It Earns a Spot in Your Stack

A few things make Kysely stand out from both the heavyweight ORMs and the older query builders:

  • Selected-column-precise result types. Select two columns and your result type has exactly those two keys, with correct nullability — not the entire row widened to any.
  • Tiny and dependency-free. The core ships with zero runtime dependencies and roughly a 2 MB install footprint. No Rust engine binary, no codegen step required.
  • Driver-agnostic dialects. Built-in support for PostgreSQL, MySQL, SQLite, and MS SQL Server, plus a thriving ecosystem of community dialects for serverless and edge drivers.
  • Runs almost everywhere. Node.js, Deno, Bun, Cloudflare Workers, and even the browser.
  • Predictable SQL. The SQL Kysely emits closely mirrors the query you wrote — no hidden N+1 surprises from a query planner you cannot see.

With around 6 million weekly downloads and active, frequent releases, it is far from obscure — it just enjoys less marketing mindshare than Prisma or Drizzle.

Getting It Into Your Project

Install the core library plus the driver for your database of choice. For PostgreSQL that means pg:

npm install kysely pg

Or with yarn:

yarn add kysely pg

The driver is your responsibility — swap in mysql2, better-sqlite3, or tedious depending on your database. Kysely itself stays lean and lets you bring exactly the connection layer you need.

Teaching Kysely About Your Schema

Everything in Kysely flows from a single TypeScript interface that describes your tables. The clever part is a handful of helper types that capture how a column behaves differently on select, insert, and update.

import {
  ColumnType,
  Generated,
  Insertable,
  JSONColumnType,
  Selectable,
  Updateable,
} from 'kysely'

export interface Database {
  person: PersonTable
  pet: PetTable
}

export interface PersonTable {
  id: Generated<number>
  first_name: string
  gender: 'man' | 'woman' | 'other'
  last_name: string | null
  created_at: ColumnType<Date, string | undefined, never>
  metadata: JSONColumnType<{
    login_at: string
    ip: string | null
    plan: 'free' | 'premium'
  }>
}

export type Person = Selectable<PersonTable>
export type NewPerson = Insertable<PersonTable>
export type PersonUpdate = Updateable<PersonTable>

export interface PetTable {
  id: Generated<number>
  name: string
  owner_id: number
  species: 'dog' | 'cat'
}

A quick tour of the helpers doing the heavy lifting here:

  • Generated<T> marks a column the database produces itself, like an auto-incrementing id. It becomes optional on insert and is never assignable on update.
  • ColumnType<S, I, U> lets you independently control the select, insert, and update types. Putting never in the update slot — as with created_at — makes a column effectively read-only after creation.
  • JSONColumnType<T> gives you a typed shape for JSON or JSONB columns.
  • Selectable<T>, Insertable<T>, and Updateable<T> derive the three concrete row shapes from one definition, so you never hand-write three nearly identical types.

One honest caveat: Kysely cannot verify that this interface matches your real database. If they drift, your types will quietly lie. Many teams avoid that risk by generating the interface from a live database with kysely-codegen, or from an existing Prisma schema with prisma-kysely.

Opening a Connection

You create a Kysely instance by handing it a dialect, which wires in the actual driver and connection pool.

import { Database } from './types'
import { Pool } from 'pg'
import { Kysely, PostgresDialect } from 'kysely'

const dialect = new PostgresDialect({
  pool: new Pool({
    database: 'test',
    host: 'localhost',
    user: 'admin',
    port: 5434,
    max: 10,
  }),
})

export const db = new Kysely<Database>({ dialect })

That db object is now your fully typed gateway to the database. Switching to MySQL or SQLite later means changing the dialect and pool here — your query code stays the same, and Kysely adapts the generated SQL per dialect, including parameter placeholders and RETURNING support.

Querying Without Losing the Plot

The query API reads almost like the SQL it produces. A select narrows its result type to exactly the columns you ask for:

export async function findWomen() {
  const people = await db
    .selectFrom('person')
    .select(['id', 'first_name'])
    .where('gender', '=', 'woman')
    .execute()

  // people: { id: number; first_name: string }[]
  return people
}

Notice the result type contains only id and first_name — nothing else leaks in. Three execution methods cover the common cases: .execute() returns an array, .executeTakeFirst() returns the first row or undefined, and .executeTakeFirstOrThrow() returns the first row or throws if there is none.

Inserts, updates, and deletes follow the same shape, and on dialects that support it you can grab generated values back with .returning():

const created = await db
  .insertInto('person')
  .values({
    first_name: 'Jennifer',
    last_name: 'Aniston',
    gender: 'woman',
  })
  .returning('id')
  .executeTakeFirstOrThrow()

await db
  .updateTable('person')
  .set({ last_name: 'Smith' })
  .where('id', '=', created.id)
  .execute()

await db.deleteFrom('person').where('id', '=', created.id).execute()

Joins are where the inference really shows off. Aliases are parsed at the type level, so 'pet.name as pet_name' produces a pet_name key in your result:

const rows = await db
  .selectFrom('person')
  .innerJoin('pet', 'pet.owner_id', 'person.id')
  .select(['person.first_name', 'pet.name as pet_name'])
  .execute()

// rows: { first_name: string; pet_name: string }[]

Reach for a leftJoin instead and Kysely automatically widens the joined columns to include | null in the result type — because that is exactly what a LEFT JOIN can produce. The types tell the truth about your SQL semantics, not a comforting fiction.

Wrapping Work in a Transaction

Transactions use a callback that receives a handle with the identical typed API as db. If the callback resolves the transaction commits; if it throws, everything rolls back.

await db.transaction().execute(async (trx) => {
  const person = await trx
    .insertInto('person')
    .values({ first_name: 'Jennifer', gender: 'woman' })
    .returning('id')
    .executeTakeFirstOrThrow()

  await trx
    .insertInto('pet')
    .values({ owner_id: person.id, name: 'Catto', species: 'cat' })
    .returningAll()
    .execute()
})

Because trx carries the same types as the main instance, there is nothing new to learn inside the block. When you need finer control there is .setIsolationLevel(...) for isolation levels, and a ControlledTransaction API for manual commit and rollback in cases where the callback form does not fit.

Evolving the Schema With Migrations

Migrations are plain TypeScript files exporting up and down functions, each receiving a Kysely<any> instance. The schema builder lives on db.schema:

import { Kysely, sql } from 'kysely'

export async function up(db: Kysely<any>): Promise<void> {
  await db.schema
    .createTable('person')
    .addColumn('id', 'serial', (col) => col.primaryKey())
    .addColumn('first_name', 'varchar', (col) => col.notNull())
    .addColumn('created_at', 'timestamp', (col) =>
      col.defaultTo(sql`now()`).notNull(),
    )
    .execute()
}

export async function down(db: Kysely<any>): Promise<void> {
  await db.schema.dropTable('person').execute()
}

You run them with the Migrator class and a provider — usually FileMigrationProvider, which reads migration files from a folder:

import { Migrator, FileMigrationProvider } from 'kysely'
import { promises as fs } from 'fs'
import * as path from 'path'

const migrator = new Migrator({
  db,
  provider: new FileMigrationProvider({
    fs,
    path,
    migrationFolder: path.join(__dirname, 'migrations'),
  }),
})

const { error, results } = await migrator.migrateToLatest()

Migrations run in alphanumeric order of their file names, so the convention is an ISO-8601-style timestamp prefix like 2026-01-01T12-00-00-create-person.ts. Kysely tracks what it has applied in a kysely_migration table, and offers migrateUp(), migrateDown(), and migrateTo(name) for finer movement. This story is deliberately Knex-like and hands-on — there is no auto-diffing migrate dev command, so you write each schema change yourself.

When You Need to Bend the Rules

No type system survives contact with reality unscathed, and Kysely gives you graceful escape hatches. The sql template tag drops you into raw SQL with typed parameters and a declared return type:

import { sql } from 'kysely'

const result = await db
  .selectFrom('person')
  .select((eb) => [
    'first_name',
    sql<number>`extract(year from age(${eb.ref('created_at')}))`.as('age_years'),
  ])
  .execute()

For cases where a column or table name is only known at runtime, the dynamic module (db.dynamic) lets you build references that still flow through the rest of the query. And since Kysely is not an ORM, it does not eager-load related entities into nested objects — but helpers like jsonArrayFrom and jsonObjectFrom from kysely/helpers/postgres let you nest related rows as typed JSON when you want a shaped result rather than flat join rows.

Where Kysely Fits

It helps to know where Kysely sits among its neighbors. Prisma is a full, batteries-included ORM with a schema-first .prisma file, automatic migration diffing, and a heavier footprint; choose it when you want the abstraction and do not mind the weight. Drizzle is the closest competitor — a lightweight, SQL-first library that leans a little more ORM-ward with generated migrations and a relational query API. Knex is the veteran that inspired Kysely's ergonomics but whose TypeScript support was added after the fact.

Reach for Kysely when you want the thinnest possible typed layer over hand-written SQL: full control of the queries you emit, Prisma-level autocompletion and result typing, a small install footprint with no codegen or engine binary, and predictable SQL that mirrors what you actually wrote. It is especially compelling if you write complex SQL with CTEs, window functions, and intricate joins that ORMs make awkward, if you are migrating off Knex, or if you target edge and serverless runtimes where every kilobyte and dependency counts.

Kysely is still pre-1.0, so read the release notes before bumping a minor version, and remember that you own the accuracy of your schema types. Those are small prices for a library that finally makes "type-safe SQL without an ORM" feel less like a wish and more like a default. Write the query you mean, and let the types follow.