Kysely, a typed SQL query builder
Kysely is a library for building SQL queries in TypeScript that autocompletes table and column names and infers the result type, while making no attempt to hide the SQL from you. The current version is 0.29.5, released on 10 August 2026, the licence is MIT, the kysely-org/kysely repository holds roughly 14.1 thousand stars, and the package has zero runtime dependencies.
What Kysely actually does
You start with an instance of the Kysely class parameterised by an interface describing your database, then call methods that mirror the parts of a query: selectFrom, innerJoin, where, orderBy, limit. At the end you call execute or executeTakeFirstOrThrow and receive an array of objects typed from whatever you passed to select. There is no session here, no lazy loading, and no query that reaches the database without your knowledge.
That distinction is the heart of the library. In a typical ORM you write user.projects and cannot tell whether you just read a field from memory or issued a query. In Kysely every query is visible in the code as a call chain that reads like SQL, because the method names are the clause names. The classic problem of queries repeated inside a loop does not disappear on its own, but at least you see it in the code rather than discovering it in the database log.
Instead of hand-written result types you get inferred ones. Pass ['projects.id', 'users.email'] to select and the result is an array of objects with exactly those two fields, while reading anything else is a compile error. If a schema column is described as string | null, that null appears in the result type and the compiler forces you to handle it. Attach a table through leftJoin and its columns become optional, because the match may not have been found.
The built-in dialects are PostgreSQL, MySQL, SQLite, MS SQL Server and PGlite, the last added in release 0.29.0. A dialect is the layer translating the query tree into a particular SQL variant and managing connections, while the driver plugs in from outside: for PostgreSQL that is usually the pg package with its own connection pool. The package itself declares no runtime dependencies, weighs 323 kilobytes as an archive, and holds 610 files.
The exports map in package.json exposes six entry points besides the main one: kysely/migration, kysely/readonly and four helper sets for particular databases. The PostgreSQL helpers are jsonArrayFrom, jsonObjectFrom, jsonBuildObject and mergeAction, functions for assembling nested results in a single query rather than several. There are a handful of plugins, enabled when creating the instance: CamelCasePlugin translates names between the underscore style in the database and the camel style in code, ParseJSONResultsPlugin parses JSON columns the driver returns as text, DeduplicateJoinsPlugin removes repeated joins.
What Kysely does not do matters more here than the feature list. It does not know your database. It does not connect to read the schema. It does not generate migrations from the difference between a model and reality. It has no studio for browsing data. Everything it knows about your tables comes from a TypeScript interface you have to supply.
Version, licence and project health
The kysely package currently has 187 published versions, the first from February 2021 and the current 0.29.5 from 10 August 2026. The major number is still zero, after more than five years of development and with clearly production use. The repository carries 432 forks, 172 open issues and 158 pages of the contributor listing at one entry per page, it is not archived, and the last change dates from 17 August 2026.
I checked the licence in three places, because that is exactly where surprises tend to sit. The LICENSE file on the main branch holds the MIT text with the line "Copyright (c) 2022 Sami Koskimäki". The license field in the npm registry for 0.29.5 reads MIT. The published archive contains package/LICENSE with precisely the same text. The GitHub programming interface reports MIT. Four sources, one answer, no discrepancy. That sounds obvious and is not: this same collection covers packages where the repository says one thing, the registry another, and the published archive holds no licence file at all. Kysely comes out exemplary here, and a dependency audit can close the matter on the first check.
There is no paid variant. There is also no company selling support and no plan with a guaranteed response time, which separates the project from those with a corporation and a hosted service behind the library. The gain is that nothing ties you to a vendor and nobody will change your pricing terms. The cost is that a serious bug leaves you with a GitHub issue and a Discord channel rather than a contract.
Two environmental constraints stand out when upgrading. The first is Node: the engines field requires at least version 22, whereas 0.28.17 asked for 20, 0.28.0 for eighteen, and 0.27.0 for fourteen. The floor rises in minor releases, because with a zero major every release is formally minor. The second is TypeScript. The exports map carries a types@<5.4 entry pointing at outdated-typescript.d.ts, which instead of real types exports Kysely, RawBuilder and sql as a special error type carrying a message about upgrading to 5.4 or newer. So you do not get a cryptic inference failure, you get a readable sentence about what is wrong, but you still have to raise the compiler version.
Where the type schema comes from
This is the most important decision when adopting Kysely and at the same time its biggest weakness. The library knows nothing about your database, so you have to hand it the table descriptions yourself. It looks like this.
import { Pool } from 'pg'
import {
Kysely,
PostgresDialect,
type ColumnType,
type Generated,
type Insertable,
type Selectable,
type Updateable
} from 'kysely'
interface UserTable {
id: Generated<number>
email: string
display_name: string | null
plan: 'free' | 'pro'
created_at: ColumnType<Date, string | undefined, never>
}
interface ProjectTable {
id: Generated<string>
owner_id: number
slug: string
archived_at: Date | null
}
export interface Database {
users: UserTable
projects: ProjectTable
}
export type User = Selectable<UserTable>
export type NewUser = Insertable<UserTable>
export type UserUpdate = Updateable<UserTable>
export const db = new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({ connectionString: process.env.DATABASE_URL, max: 10 })
})
})The whole power of this notation sits in the ColumnType type, which takes three parameters: the type on read, the type on insert and the type on update. A created_at column described as ColumnType<Date, string | undefined, never> returns a date object on read, accepts a string or nothing on insert, and cannot be changed on update. The Generated<number> type is shorthand for a column the database fills in itself, so it is optional on insert. There is also GeneratedAlways for columns that must never be written to, and JSONColumnType for data the driver returns as text. The Selectable, Insertable and Updateable helpers turn a table description into three separate object shapes, and those are what you use in your own function signatures, never the raw table interface.
There are three routes to obtaining that schema. The first is writing it by hand, sensible on a small database and with full control over migrations, tiring across sixty tables. The second is kysely-codegen, currently at version 0.20.0 under the MIT licence, which connects to a live database and writes out a finished type file. The third is prisma-kysely at version 3.2.1, a generator plugged into Prisma that takes schema.prisma as the source of truth and emits types for Kysely, which makes sense if Prisma runs your migrations while you want to write queries without its engine.
npx kysely-codegen \
--dialect postgres \
--url "$DATABASE_URL" \
--out-file ./src/db/schema.d.ts \
--camel-case \
--exclude-pattern "public._prisma*" \
--type-only-imports
npx kysely-codegen --dialect postgres --url "$DATABASE_URL" --verifyThe second command is the one easiest to forget and the key piece of the puzzle. The --verify flag writes no file, it only checks whether the existing one matches the current database state, and exits with a non-zero code on divergence. Without that step in the continuous integration pipeline, sooner or later somebody adds a column through a migration, skips regenerating the types, and the compiler keeps insisting everything is fine. Kysely types are not checked against the database at run time, so if you lie in the interface the library takes your word for it and the error surfaces as a driver exception in production.
The generator supports the postgres, mysql, sqlite, mssql, libsql, bun-sqlite and worker-bun-sqlite dialects, and beyond the flags shown it offers among others --overrides, --type-mapping, --include-pattern, --singularize, --runtime-enums, --date-parser, --numeric-parser, --default-schema and --config-file. It is a separate package with a separate maintainer, so its release cadence is not the release cadence of Kysely, and after a larger change in the library it can lag behind for a while.
Writing queries
Below is a typical read with an optional condition and a join, the sort of query that looks much the same in every application.
import { sql } from 'kysely'
async function findActiveProjects(ownerId: number, search?: string) {
return db
.selectFrom('projects')
.innerJoin('users', 'users.id', 'projects.owner_id')
.select([
'projects.id',
'projects.slug',
'users.email',
sql<number>`count(*) over ()`.as('total_count')
])
.where('projects.owner_id', '=', ownerId)
.where('projects.archived_at', 'is', null)
.$if(search !== undefined, (qb) =>
qb.where('projects.slug', 'like', `%${search}%`)
)
.orderBy('projects.slug', 'asc')
.limit(50)
.execute()
}
const owner = await db
.selectFrom('users')
.selectAll()
.where((eb) =>
eb.or([eb('users.email', '=', 'ada@example.com'), eb('users.id', '=', 1)])
)
.executeTakeFirstOrThrow()The $if method solves a problem that in query builders usually ends in branching code and a lost type. You pass the condition as a boolean and the query extension as a function, and the result type is computed correctly in both cases. Methods starting with a dollar sign are a consistent convention for operations acting at the type level rather than the SQL level: alongside $if sit $castTo, $narrowType, $assertType, $asScalar and $call.
The function handed to where receives an expression builder, conventionally named eb. It is itself callable as a three-argument comparison, and beyond that offers and, or, not, between, exists, ref, val, lit, cast, case, selectFrom and several more. This is the mechanism for composing nested conditions that a flat call chain cannot express.
The sql template tag is the escape hatch for everything the library has not wrapped. The type parameter in sql<number> tells the compiler what you expect, and responsibility passes to you there, because nobody will verify it. Values interpolated into the template become query parameters rather than text, so the basic case is safe. The tag also carries helpers: sql.ref for a column reference, sql.table for a table, sql.id for an identifier, sql.lit for a literal, sql.join for a list, and sql.raw for raw text. The last one pastes a string into the query with no processing whatsoever and is the single place in the whole interface where SQL injection is easy.
Writing data looks symmetrical, with conflict handling exposed directly through the equivalent of the on conflict clause.
const inserted = await db
.insertInto('users')
.values({ email: 'ada@example.com', display_name: 'Ada', plan: 'pro' })
.onConflict((oc) =>
oc.column('email').doUpdateSet({ display_name: 'Ada', plan: 'pro' })
)
.returningAll()
.executeTakeFirstOrThrow()
const { sql: text, parameters } = db
.selectFrom('users')
.select('id')
.where('plan', '=', 'pro')
.compile()The conflict builder offers column, columns, constraint, doNothing, doUpdateSet and where, the full set needed for an insert with update. The compile method finishes building without sending anything and returns an object with the fields sql, parameters, query and queryId. It helps in two situations: when you want to see the generated text in a test rather than guess at it, and when you need a query prepared once and executed many times.
Transactions, migrations and the library's boundaries
A transaction is enclosed in a callback, with commit and rollback happening automatically depending on whether the function threw. The object passed inside carries the same interface as the main instance, so query code cannot tell whether it runs inside a transaction.
await db.transaction().execute(async (trx) => {
const user = await trx
.insertInto('users')
.values({ email: 'ada@example.com', plan: 'free' })
.returning('id')
.executeTakeFirstOrThrow()
await trx
.insertInto('projects')
.values({ owner_id: user.id, slug: 'default' })
.execute()
})Migrations are present in the library, but in a minimal and entirely manual form. A migration file is a module exporting two functions, up and down, and schema building follows the same chained style as queries.
import { Kysely, sql } from 'kysely'
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('projects')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn('owner_id', 'integer', (col) =>
col.notNull().references('users.id').onDelete('cascade')
)
.addColumn('slug', 'varchar(64)', (col) => col.notNull())
.addColumn('archived_at', 'timestamptz')
.execute()
await db.schema
.createIndex('projects_owner_id_index')
.on('projects')
.column('owner_id')
.execute()
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('projects').execute()
}Running them is the job of the Migrator class, which you hand a database instance and a file provider.
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { Migrator, FileMigrationProvider } from 'kysely'
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(process.cwd(), 'migrations')
}),
migrationTableName: 'kysely_migration',
allowUnorderedMigrations: true
})
const { error, results } = await migrator.migrateToLatest()Beyond migrateToLatest you get migrateUp, migrateDown, migrateTo and getMigrations, and the configuration lets you change the migration table name, the lock table name, and the schema both are created in. The allowUnorderedMigrations option earns its keep in a team working across several branches, because without it a migration with an earlier timestamp added after a merge gets rejected. The call returns an object with the fields error and results, both optional, so handle both rather than only the exception.
This is where the main boundary against full-featured tooling runs. Kysely will not compute the difference between database state and your model, and will not write the migration for you. Every schema change is written by hand and the types are regenerated separately, so one column change is two actions instead of one. There is no command line interface in the library either, that lives in the separate kysely-ctl package at version 0.21.0.
The compiler-side cost
This library computes almost everything at the type level and you pay for it. A query result type is produced by processing the database interface, the join list and the selected column list, and each of those is compiler work, redone on every check of the file in the editor. With a database of several dozen tables and queries carrying a few joins, language server response time becomes noticeable.
The team says so openly. The release notes for 0.29.5 open with the statement that TypeScript 7 allows deeper computations, which causes far more type instantiations and longer wall clock times in various scenarios, and one of the fixes in that release concerns exactly the optimisation of assignability checks between builders. A separate fix carries a title about repairing infinite recursion during type checking. These are not theoretical problems.
Release 0.29.0 added two tools addressing that cost head on: $pickTables and $omitTables. Both narrow the database view for the rest of the chain, so the compiler processes a description of two tables instead of fifty. The same release introduced the ReadonlyKysely type imported from kysely/readonly, which strips writing methods from an instance at compile time, turning insertInto, updateTable, deleteFrom and mergeInto into errors. That is useful in code reading from a replica.
The practical conclusion is simple. If the database is large, keep the type file outside continuous checking where you can, split the database description into modules, and reach for view narrowing wherever the editor starts to drag. On a small or medium database you will notice nothing.
Kysely against the alternatives
| Feature | Kysely | Drizzle ORM | Prisma | The pg driver with no wrapper |
|---|---|---|---|---|
| Working model | methods mirroring SQL clauses | methods mirroring SQL clauses | its own query language and relations | SQL as a string |
| Where the type schema comes from | by hand or a separate generator | a schema file in TypeScript | a schema.prisma file | no result typing |
| Migrations from schema diffs | none | yes, through drizzle-kit | yes, through prisma migrate | none |
| Runtime dependencies | none | none | six packages in prisma | six packages in pg |
| Current version | 0.29.5 | 0.45.2 | 7.9.1 | 8.23.0 |
| Licence | MIT | Apache 2.0 | Apache 2.0 | MIT |
The choice comes down to one question: do you want one source of truth for the schema, or two. Drizzle and Prisma give you one, because types and migrations both come from the same description, and that is their genuine advantage, one Kysely does not even try to dispute. Kysely gives you two and expects you to keep them in step yourself, in exchange imposing nothing on either migrations or the way queries are written.
You pick Kysely when the database already exists and is not yours to govern, when the queries are non-trivial and writing them in somebody else's query language would be a fight, or when you want certainty that nothing reaches the database without your knowledge. It works well as a data access layer over plain PostgreSQL, and equally over hosted databases such as Supabase or Neon, where you connect through an ordinary driver anyway. You do not pick it when the team is small, the database new, and the priority is standing up a schema together with migrations quickly.
Common mistakes
The first is a table description that does not match the database. Types are not checked at run time, so a typo in a column name or a missed nullable value produces code that compiles and breaks only at query time. Put kysely-codegen --verify into the continuous integration pipeline.
The second is using the raw table interface where Selectable belongs. The UserTable type contains Generated and ColumnType, descriptions of three different shapes at once, and is unfit as the type of an object returned from the database. Describe functions taking and returning data through Selectable, Insertable and Updateable.
The third is executeTakeFirst where a missing result is an error. That method returns an empty value which is easy to overlook, because the result type lets you carry on. If the record must exist, use executeTakeFirstOrThrow.
The fourth is sql.raw with user-supplied data. Interpolation in the sql tag creates a parameter and is safe, but sql.raw pastes text literally. For a dynamic column or table name there are sql.ref, sql.table and sql.id.
The fifth is enabling CamelCasePlugin halfway through a project. The plugin translates names in both directions, so once it is on, the database interface has to be written in camel case rather than with underscores. Making that change in a running project means rewriting every table description and regenerating types with the --camel-case flag.
The sixth is bumping a minor version without reading the notes. The major number is zero, so backwards-incompatible changes fall within the versioning rules. The Node floor moving from 20 to 22 arrived in exactly such a release and can stop a server build.
The seventh is queries repeated inside a loop. The absence of lazy loading does not mean the problem is gone, only that you create it explicitly. For nested results use jsonArrayFrom and jsonObjectFrom from kysely/helpers/postgres, or a join with aggregation.
FAQ
Is Kysely an ORM?
No, and deliberately so. It lacks everything that defines object-relational mapping: no session, no unit of work, no lazy loading, no entity objects with identity. It is a layer that builds SQL and types the result, so you keep thinking in tables and clauses.
Where do I get the interface describing the database?
From one of three places: write it by hand, generate it from a live database with kysely-codegen at version 0.20.0, or from a schema.prisma file with prisma-kysely at version 3.2.1. When generating, add the --verify step to the pipeline so that divergence between types and database stops the build.
Will Kysely handle migrations for me?
It will run them and record history in a table, but it will not write them. The Migrator class executes files exporting up and down functions, and you write the bodies of those functions yourself using the schema builder. Nobody here computes the difference between database state and a model.
Why does the editor slow down on a large database?
Because result types are computed by the compiler on every check, and the cost grows with the size of the database interface and the number of joins. The team is working on optimisation, as the 0.29.5 release notes state plainly. As an immediate remedy, narrow the view with $pickTables or $omitTables.
Is Kysely paid, and does the licence restrict anything?
No and no. The package is under the MIT licence, confirmed consistently by the file in the repository, the field in the npm registry, and the LICENSE file inside the published archive. There is no paid tier, but there is also no company selling support, so a serious bug leaves you with an issue in the repository.
Can Kysely be used alongside Prisma?
Yes, and the arrangement is common enough. Prisma owns the schema and migrations, the prisma-kysely generator turns the schema file into types for Kysely, and you write the harder queries with the builder. The cost is maintaining two libraries in the project, so it mostly makes sense during a gradual migration.
Documentation and examples live on the project site, the source code in the GitHub repository, and release metadata in the npm registry. For the full picture it helps to read this alongside the article on TypeScript, because some of Kysely's limitations are simply limitations of the compiler.