Skip to main content

Kysely vs Drizzle ORM

Kysely and Drizzle ORM start from the same premise: SQL is the interface, and TypeScript should check it. Both compose statements through a typed API and bind values as parameters, with no generated client. Both infer the result of a query. Drizzle knows what each column is, and Kysely also knows whether the query can see it.

Both are well adopted, maintained, and lead their category: Drizzle is the most downloaded ORM on npm, and Kysely the most downloaded SQL query builder. Drizzle brought SQL-first TypeScript to the mainstream, and every SQL-first library, Kysely included, has a bigger audience for it.

Drizzle's builder is SQL-like in clause order and JavaScript in the rest: columns are objects, operators are imported functions, and aliases are variables. Kysely writes SQL's own operators and aliases, and carries the tables in scope through every step, so a reference is checked where it is written.

The differences that cost most are the quiet ones. A JSON or CASE expression that Drizzle writes as an annotated template can return a wrong value under a type that still compiles, and in Drizzle's dynamic mode a second where() replaces the first with no error, even when the first was a tenant filter.

Kysely is a type-safe SQL query builder. Drizzle calls itself a headless TypeScript ORM. In this site's terms it is a data framework, the familiar stack: models, which Drizzle calls tables, migrations generated from them, a model API, which Drizzle calls a relational query builder, and a SQL-style query builder. This page compares that builder with Kysely.

Drizzle here means 0.45.4 and 1.0.0-rc.5, its stable release and its release candidate in November 2026. Its behavior below is the same in both unless stated.

Both infer the result of a join​

Assume configured Kysely (db) and Drizzle (drizzleDb) clients over the same two tables:

Database interface and Drizzle table objects

Kysely's Database interface is types only, handwritten or generated from the database:

interface Database {
person: { id: number; first_name: string; age: number | null }
pet: { id: number; name: string; owner_id: number }
}

Drizzle's tables are runtime objects, which Drizzle Kit also reads to generate migrations:

import { integer, pgTable, text } from 'drizzle-orm/pg-core'

const person = pgTable('person', {
id: integer().notNull(),
first_name: text().notNull(),
age: integer(),
})
const pet = pgTable('pet', {
id: integer().notNull(),
name: text().notNull(),
owner_id: integer().notNull(),
})

Select adults with their pets' names, keeping people who have no pet.

With Drizzle:

import { eq, gte } from 'drizzle-orm'

const rows = await drizzleDb
.select({ first_name: person.first_name, pet_name: pet.name })
.from(person)
.leftJoin(pet, eq(pet.owner_id, person.id))
.where(gte(person.age, 18))
.orderBy(person.first_name)

With Kysely:

const rows = await db
.selectFrom('person')
.leftJoin('pet', 'pet.owner_id', 'person.id')
.select(['person.first_name', 'pet.name as pet_name'])
.where('person.age', '>=', 18)
.orderBy('person.first_name')
.execute()

The rows match, and so do the inferred types: { first_name: string; pet_name: string | null }[]. Both libraries make pet_name nullable because the join is a left join.

Drizzle can also return the pet's columns as a nested object: the statement stays flat, and Drizzle's result mapper groups the columns after the rows arrive, as null when every column of the join is. Kysely returns the row as the database shaped it, each joined column nullable on its own, and nests in SQL when a nested object is wanted. Written correctly, the two queries are equivalent. The libraries differ in what the compiler says when a query is wrong.

Both check each column, and Kysely also checks the query​

Production queries collect their filters. Put the query above in a function with an optional pet name, collecting conditions the way Drizzle's guide does. Then a later change drops the pet's name from the result, and the join with it:

import { and, eq, gte } from 'drizzle-orm'

function matchingPeople(options: { petName?: string }) {
const filters = [gte(person.age, 18)]
if (options.petName) filters.push(eq(pet.name, options.petName))

return drizzleDb
.select({ first_name: person.first_name })
.from(person)
.where(and(...filters))
}

It compiles, and it runs for every request without a pet name. A request with one fails in PostgreSQL: the filter still reads pet, and the change never touched that line. The same function after the same change in Kysely:

function matchingPeople(options: { petName?: string }) {
let query = db
.selectFrom('person')
.select('person.first_name')
.where('person.age', '>=', 18)

if (options.petName) {
// TypeScript error: pet is not available in this query.
query = query.where('pet.name', '=', options.petName)
}
return query
}

A Drizzle column is a value. pet.name has one type wherever it is passed, eq() returns a condition that records no table, and select() is called before from(), so a selection is typed before the query has a table. Kysely's selectFrom comes first, and every later method is typed against the tables and aliases accumulated so far. With Drizzle, TypeScript accepts a column object in any query. With Kysely, it checks every reference written on the query's own builder.

The gap is a design choice​

The gap is structural, not a missing feature, and it is a choice. Asked in 2024 for a fully type-safe query builder, Drizzle's team answered that it had "deliberately switched to our current API" because "it will provide better DX than huge type errors that are quite hard to understand for average TS developers", and that full type safety would need joins to come before the selection, "deviating the API from SQL even more".

By that account, closing the gap means changing the order every query is written in. Kysely's order is the one PostgreSQL processes a query in: the FROM list first, the output expressions after it. Kysely puts from first for the reason SQL evaluates it first: a query's inputs have to be known before its outputs can be checked. Its bet is that a mistake reported at compile time costs less than the same mistake at runtime, for a developer and for a coding agent alike.

Aliases and CTEs are declared inside the statement​

The same design decides where a name is declared. SQL writes an alias where the table is used, join person as other, and so does Kysely: .innerJoin('person as other', 'other.age', 'person.age'). In Drizzle a column has to hang off an object before a selection or a join condition can name it, so an alias, a derived table, and a CTE are each declared in a variable ahead of the query.

A Drizzle CTE then takes three steps: $with('adults').as(...) declares it, .with(adults) attaches it, and .from(adults) uses it. A query that skips the middle step compiles and fails in PostgreSQL. Kysely's .with('adults', ...) declares the CTE and puts it in scope in one call, and selectFrom('adults') without it is a type error. Drizzle keeps SQL's clause order and builds the rest from JavaScript values. Kysely keeps SQL's words, scope, and single statement, and moves one clause to check them.

The same split in every clause​

The split is the same wherever a reference lands. That includes ORDER BY, GROUP BY, HAVING, an update's SET, a delete's WHERE, and these:

Invalid referenceKyselyDrizzle
Select pet.name without joining petTypeScript error.Passes TypeScript; Drizzle throws while generating the SQL.
Filter on pet.name without joining petTypeScript error.Passes TypeScript; PostgreSQL rejects the query.
Join a subquery aliased pets, then compare pet.owner_id in onTypeScript error.Passes TypeScript; PostgreSQL rejects the query.
Partition a window by a column of a table the query never joinedTypeScript error.Passes TypeScript; PostgreSQL rejects the query.
Reference a table the query never joined inside a sql fragmentTypeScript error, through eb.ref.Passes TypeScript; PostgreSQL rejects the query.

Drizzle catches the first of these itself, with an error that names the table and asks whether you forgot to join it. The check exists, and it runs while generating the SQL, after the compiler has passed the code. Harminder Virk, the creator of AdonisJS, documented the first three rows in December 2023, and all three still pass TypeScript in 0.45.4 and 1.0.0-rc.5.

Every row in that table is a failed run with Drizzle and a compile error with Kysely. PostgreSQL stops at the first error, so unchecked mistakes surface one execution at a time, against a database. The compiler reports them together, before a connection is opened, which is also the loop a coding agent can run on every edit.

Operand types, select alias scope, and RETURNING

Operands follow the same split. Drizzle's operators are imported functions, inArray for IN and ne for <>, and each checks a literal against its column: eq(person.id, 'one') is a type error, while eq(person.first_name, person.id), a text column compared with an integer column, compiles and PostgreSQL rejects it. So does where(and(person.id)), a column that is not a condition.

Kysely takes SQL's operators as strings and completes them in place, and an expression past a plain comparison is written inside an (eb) => ... callback. Its expression builder checks an expression against an expression, so eb('first_name', '=', eb.ref('id')) is a type error, and so is a condition that is not boolean.

PostgreSQL computes WHERE and HAVING before the selection, so a select alias is not visible to them: Kysely's where and having reject one at compile time, and groupBy and orderBy accept it. Drizzle compiles all four. An expression named with .as('name') and passed to where() is sent as where "name" = $1, and PostgreSQL rejects it. Reached through the where callback, Drizzle repeats the expression and the query runs.

One reference failure is quiet: returning({ id: pet.id }) on an insert into person compiles and returns the person's id, because Drizzle writes the column without its table.

Loud failures and quiet ones​

Most of these failures are loud once a test runs the branch that builds the reference. The matchingPeople function above passes every test that leaves the pet name out.

The failures further down this page are quiet: a misspelled JSON key returns null, a CASE without an ELSE returns null under a string type, a limit() on one branch of a shared query applies to the other, and a second where() drops the first. An unchecked template or a mutated builder returns the wrong result with no error at all.

No query builder is perfectly type-safe, and Kysely is no exception. Type safety is what it is built around, and every gap is treated as work to do. TypeScript's native compiler widens what that work can afford, and Kysely's direction is to spend that headroom on stricter and more ambitious checks.

Where Kysely's checks stop

Kysely's check is made where a reference is written, on a builder that knows the query, and it stops there. A reference written with sql.ref or as template text, a dynamic reference, an expression built ahead of time on a standalone expressionBuilder, and a helper whose builder parameter declares more tables than the caller's query has are all taken on trust. The reference shorthands whereRef, onRef, and the three-argument join take two column names without comparing their types.

Its scope rules are PostgreSQL's on every dialect. It rejects a select alias in where and having. MySQL accepts one in HAVING and SQLite in WHERE as well, and Kysely makes you repeat the expression there.

Column objects pay Drizzle back here. An editor can rename one and jump to its declaration, and Drizzle qualifies it with its table in a join. Kysely's references are strings read by the type system: completed and checked, yet an unqualified id after joining two tables that both have one compiles, and PostgreSQL rejects it as ambiguous.

where('age', '=', null) compiles and matches no rows, where Drizzle's eq(person.age, null) is a type error. That is by design: Kysely builds the comparison as written, and = null is valid SQL. Code that filters on a nullable variable adds the built-in SafeNullComparisonPlugin, which rewrites = to IS when the value is null, and nothing is rewritten without it.

One Kysely gap is quiet: a union whose sides list the same columns in a different order returns values under the wrong keys, where Drizzle throws before sending it.

Kysely builds the expressions Drizzle writes as templates​

Past selections, joins, filters, and plain aggregates, Drizzle's builder hands over to its sql template. JSON traversal, CASE, aggregate FILTER, and window clauses are SQL text with a result type declared by hand. Kysely has a builder for each, so the references are checked, and for JSON and CASE the type is inferred.

JSON traversal​

A user_settings.profile column holds JSON typed as Profile, which is { addresses: { city: string }[] }, in both libraries: in Kysely's Database interface, and through jsonb().$type<Profile>() on Drizzle's settings table object. Read the first address's city. With Drizzle:

import { sql } from 'drizzle-orm'

const rows = await drizzleDb
.select({
id: settings.id,
first_city: sql<string | null>`
${settings.profile}->'addresses'->0->'city'
`.as('first_city'),
})
.from(settings)
// The string | null type is supplied by hand.

With Kysely:

const rows = await db
.selectFrom('user_settings')
.select('id')
.select((eb) =>
eb.ref('profile', '->').key('addresses').at(0).key('city').as('first_city'),
)
.execute()
// Inferred: { id: number; first_city: string | null }[]

Kysely infers string | null because the array may be empty. Now misspell city as ctiy:

// Drizzle: compiles, runs, and returns null for every row.
sql<string | null>`${settings.profile}->'addresses'->0->'ctiy'`

// Kysely: TypeScript error, ctiy is not a key of an address.
eb.ref('profile', '->').key('addresses').at(0).key('ctiy')

A misspelled JSON key is a silent null in Drizzle and a compile error in Kysely. Kysely also rejects reading city before indexing addresses. Both libraries trust the declared Profile type, and neither validates the stored JSON.

CASE expressions​

Label each person by age, including people whose age is unknown. With Drizzle:

import { gte, lt, sql } from 'drizzle-orm'

const query = drizzleDb
.select({
id: person.id,
age_group: sql<string>`case
when ${gte(person.age, 18)} then ${'adult'}
when ${lt(person.age, 18)} then ${'minor'}
else ${'unknown'}
end`.as('age_group'),
})
.from(person)

With Kysely:

const query = db
.selectFrom('person')
.select('id')
.select((eb) =>
eb
.case()
.when('age', '>=', 18)
.then('adult')
.when('age', '<', 18)
.then('minor')
.else('unknown')
.end()
.as('age_group'),
)
// Inferred: { id: number; age_group: string }[]

Both return the same labels. Now remove the else. Kysely infers string | null, and a caller's .toUpperCase() stops compiling until it handles the null. Drizzle's sql<string> is unchanged, still claims a string, and a person with no age returns null. Drizzle's own guide writes CASE as a template, and the request for native CASE support has been open since August 2023. A sql<T> annotation is a claim about the expression. Kysely's inferred type is derived from it.

Aggregate filters and windows​

Count images and videos in one row, the shape Immich builds with Kysely. Each count is one selection:

// Drizzle: a template.
sql<string>`count(*) filter (where ${eq(asset.kind, 'image')})`

// Kysely: a builder.
eb.fn.countAll<string>().filterWhere('kind', '=', 'image')

Both declare string here, as pg returns a PostgreSQL bigint by default, and Kysely's countAll takes the type as a parameter and converts nothing. Drizzle's count() with .where() and db.$count(table, filter) cover a single conditional count and return it as a number. Its count() has no FILTER or OVER methods, so several filtered aggregates in one row, or any window, are templates.

In Kysely a window is .over() on the aggregate, and a partition by a table the query never joined is the fourth row of the table above. A filtered or windowed aggregate is a template in Drizzle and a builder in Kysely: both declare its result type, and only the builder checks its references against the query.

Kysely covers more of SQL's statements​

Whole statements and clauses follow the same line. Each row below is a method in Kysely, with no builder for it in Drizzle 0.45.4 or 1.0.0-rc.5, and most are requests in Drizzle's issue tracker:

Statement or clauseKyselyDrizzle
WITH RECURSIVEdb.withRecursive('tree', ...)Requested in March 2023.
MATERIALIZED and NOT MATERIALIZED on a CTEcte('adults').materialized()Requested in May 2024.
MERGEdb.mergeInto('person as target')Requested in September 2023.
DELETE ... USING, and DELETE with a join on MySQLdeleteFrom('pet').using('person'), .innerJoin(...)Requested in June 2023 and October 2024.
Row-value IN: (id, owner_id) in ((1, 1), (3, 1))eb(eb.refTuple('id', 'owner_id'), 'in', [eb.tuple(1, 1)])Requested in November 2024; an earlier request was closed as not planned.
NULLS FIRST and NULLS LASTorderBy('age', (ob) => ob.desc().nullsLast())Requested in December 2023.
IS DISTINCT FROMwhere('age', 'is distinct from', 40)Requested in December 2023.
ON CONFLICT ON CONSTRAINToc.constraint('pet_pkey').doNothing()The conflict target takes columns only.
excluded in ON CONFLICT DO UPDATEdoUpdateSet((eb) => ({ name: eb.ref('excluded.name') }))Written as sql.raw in the upsert guide; a typed form was requested in December 2023.
coalesceeb.fn.coalesce('age', eb.lit(0))Requested in December 2024.

Each of them is a sql template in Drizzle today, with the references in it unchecked. The row-value request was closed with the note that inArray "shouldn't be used for this purpose according to the Postgres, MySQL and SQLite docs"; PostgreSQL documents the comparison as a row constructor comparison, and Kysely's refTuple writes it.

Drizzle's builder does cover exists, lateral joins, DISTINCT ON, and row locks, so the gaps sit at the edges of the language. Statement coverage is the same difference again: a clause Drizzle writes as text, Kysely writes as a method with its references checked.

Kysely's escape hatch keeps references checked​

Kysely does not cover all of SQL either. It covers what applications write most, extends that coverage release by release, and types the escape hatch for the rest. Neither library's like takes an ESCAPE clause, so both write one in a sql template that binds its values as parameters. They differ in what an interpolated reference is:

// Drizzle: compiles. pet is not in the query, and PostgreSQL rejects it.
drizzleDb
.select({ id: person.id })
.from(person)
.where(sql<boolean>`${pet.name} like ${pattern} escape '!'`)

// Kysely: eb.ref is checked against the tables in scope.
db.selectFrom('person')
.select('id')
.where(
(eb) => sql<SqlBool>`${eb.ref('first_name')} like ${pattern} escape '!'`,
)
// Replace first_name with pet.name: TypeScript error, pet is not joined.

Neither library checks a fragment's grammar or its declared result type. Drizzle's sql template carries SQL text, bound values, and a declared type, and Kysely's also carries the references eb.ref checked. Wrap repeated fragments in reusable helpers that take Expression<T>. See the sql API for more. For whole queries written as templates, see query builder vs raw SQL.

Nested results stay in the same builder​

Return each person with their first two pets by name, as one nested array per row. Drizzle's SQL builder has no JSON helper. Its joins guide groups joined rows into nested objects in JavaScript with reduce, after they arrive, and nesting in SQL is a sql template around a subquery:

import { asc, eq, sql } from 'drizzle-orm'

const pets = drizzleDb
.select({ name: pet.name })
.from(pet)
.where(eq(pet.owner_id, person.id))
.orderBy(asc(pet.name))
.limit(2)

const rows = await drizzleDb
.select({
first_name: person.first_name,
pets: sql<{ name: string }[]>`
(select coalesce(json_agg(pet_rows), '[]'::json) from ${pets.as('pet_rows')})
`.as('pets'),
})
.from(person)
// The { name: string }[] type is supplied by hand.

Kysely has a helper for it, jsonArrayFrom from kysely/helpers/postgres:

import { jsonArrayFrom } from 'kysely/helpers/postgres'

const rows = await db
.selectFrom('person')
.select((eb) => [
'person.first_name',
jsonArrayFrom(
eb
.selectFrom('pet')
.select('pet.name')
.whereRef('pet.owner_id', '=', 'person.id')
.orderBy('pet.name')
.limit(2),
).as('pets'),
])
.execute()
// Inferred: { first_name: string; pets: { name: string }[] }[]

Both send one statement and return the same rows, [] included for a person with no pets. The helper infers its type from the subquery, and the template declares one. Leave out the coalesce and the template still claims an array while a person with no pets gets null.

Rename a key in the subquery, { pet_name: pet.name }, and Drizzle still writes select "name": its selection keys are applied by the result mapper, which never sees the inside of json_agg, so the JSON arrives keyed name under a type that says pet_name. Kysely's 'pet.name as pet_name' is a SQL alias, and the key and the type change together.

The nested subquery is an ordinary Kysely query: join inside it, aggregate it, filter it, with the same methods and the same checks, and jsonObjectFrom returns one row as an object, null when there is none. A nested result is a template with a declared type in Drizzle's SQL builder, and a helper with an inferred type in Kysely's. The helpers are per dialect, and a driver that returns JSON as text needs ParseJSONResultsPlugin. See nested relations. A request to port jsonArrayFrom to Drizzle has been open since October 2023.

Drizzle's relational queries are the shorter way to read a declared relation: findMany with a with clause returns the same nested type in one statement, from a model API beside the SQL builder.

A read that needs a join outside the declared relations, a GROUP BY, or an aggregate other than a per-parent count goes back to the SQL builder, as the docs say: "As of now aggregations are not supported in extras, please use core queries for that." There the nesting is the template above. SQL query builder vs ORM compares that kind of API with a query builder.

Shared queries branch without mutation​

Drizzle mutates the shared base:

import { gte } from 'drizzle-orm'

const base = drizzleDb
.select({ id: person.id })
.from(person)
.where(gte(person.age, 18))

const preview = base.limit(1)
const everyone = base
// Both now have LIMIT 1.

Kysely branches independently:

const base = db.selectFrom('person').select('id').where('age', '>=', 18)
const preview = base.limit(1)
const everyone = base
// everyone has no limit.

Drizzle's types assume a query written in one go. Outside dynamic mode, its types remove a method once it has been called, which the docs say "becomes a problem" for "a shared function that takes a query builder and enhances it". $dynamic() lifts that restriction and keeps the mutation, and a second where() replaces the first:

import { eq } from 'drizzle-orm'

const ownedPets = drizzleDb
.select({ id: pet.id })
.from(pet)
.where(eq(pet.owner_id, ownerId))
.$dynamic()

const named = ownedPets.where(eq(pet.name, 'Catto'))
// select "id" from "pet" where "pet"."name" = $1
// The owner filter is gone, from ownedPets too.

The query now returns every owner's pets named Catto, with no error. When the first filter is an owner or a tenant scope, that is a data leak.

A Drizzle maintainer called this expected behavior and closed the request to merge the calls as not planned. The documented pattern is to collect conditions and call where(and(...)) once, with a function that builds a fresh query for each branch. In Kysely each where() adds to the last with AND and returns a new builder, so a shared function can add a filter, a join, or a limit without knowing what its caller added.

Sharing a Drizzle builder means building it again. In Kysely, every builder is a value you can share. Immutability has one trap of its own: a returned builder that is not kept is a change that never happened. Keep each returned builder, and use $if when conditional selections change the result type.

Building a query never executes it​

A Drizzle builder is thenable: awaiting it runs the query. That keeps simple calls short, and it means an async helper executes a query it meant to return for further composition:

import { gte } from 'drizzle-orm'

async function adultQuery() {
return drizzleDb
.select({ id: person.id })
.from(person)
.where(gte(person.age, 18))
}

const rows = await adultQuery()
// Already executed. These are rows, not a builder to extend.

Keep the helper synchronous, or return an object containing the builder to avoid promise assimilation. With Kysely, even an async helper returns a composable builder:

async function adultQuery() {
return db.selectFrom('person').select('id').where('age', '>=', 18)
}

const query = await adultQuery()
const rows = await query.where('first_name', '=', 'Jennifer').execute()

A Drizzle builder runs when it is awaited, and a Kysely builder runs when you call .execute().

Cancellation, streaming, and controlled transactions are calls on the builder​

Execution also takes a signal. Every query's execute() and stream() accepts a web-standard AbortSignal, and an option chooses what happens to a query already running: stop waiting, cancel it on the database, or kill its session. The PostgreSQL, MySQL, and MSSQL dialects implement the database side. Drizzle's query API takes no signal in 0.45.4 or 1.0.0-rc.5, and the request for one has been open since December 2023.

Two more execution shapes are calls on the builder. stream() returns a select's rows as an async iterable through the PostgreSQL, MySQL, MSSQL, and SQLite dialects, where Drizzle has an iterator() for MySQL only and a request open since April 2023. db.startTransaction().execute() returns a transaction controlled by commit(), rollback(), and savepoints, beside the callback form both libraries have and requested in Drizzle since August 2023.

Drizzle's callback form nests: a tx.transaction() inside another runs as a savepoint. Kysely's callback transaction does not nest, and savepoints are calls on the controlled form. Kysely's log event also carries each query's duration, requested in Drizzle since July 2024. Cancellation, streaming, and a controlled transaction are methods in Kysely, and each is an open request in Drizzle's issue tracker.

One handle type for the database and a transaction​

Reusable helpers take a handle. A Kysely helper typed Kysely<Database> serves the regular instance and a transaction alike, because Transaction<Database> extends Kysely<Database>: the same insertPerson(db, person) runs as insertPerson(trx, person) inside db.transaction().execute(), with no second signature.

The handle drizzle() returns carries a $client property that Drizzle's transaction type lacks, so a helper parameter typed typeof db rejects tx with "Property '$client' is missing". The maintainers closed the report as not a bug, since "the transaction and database types are different", and the helper is typed with the exported NodePgDatabase<typeof schema> type instead. One Kysely<Database> parameter serves the database and every transaction on it, so a helper is written once.

Kysely is extended through its query tree, not its builders​

Neither library has a public API for adding methods to its builders, and both are extended with functions. Kysely's $call applies a function that takes and returns a builder in the middle of a chain, and anything that implements Expression<T> is accepted wherever Kysely takes an expression, which is how your own helpers add what the builder lacks. Drizzle's helpers do the same by returning sql<T>.

What only Kysely opens is the query underneath. Before it is SQL, every Kysely query is an immutable tree of operation nodes: a select node that holds its joins, its where, and its limit as nodes of their own. toOperationNode() returns it from any builder, the node types and OperationNodeTransformer are exported, and plugins and dialects are interfaces built on it.

Plugins see the query​

A Kysely plugin receives that tree before it compiles, and the result after it runs. The built-in plugins are written against that interface: they convert camelCase, qualify tables with a schema, deduplicate joins, handle empty in lists, make null comparisons safe, and parse JSON results. withPlugin scopes one to a query or a derived instance and leaves the original unchanged.

Drizzle's extension points are per concern: a logger that receives the SQL text and its parameters, a cache, casing in the schema declaration, and custom column types that convert values. A Drizzle builder keeps its clauses in a protected field, and its dialect writes them out as chunks of text, names, columns, and parameters. Neither Drizzle version has an interface that receives the query in between.

A custom plugin: a read-only instance

A plugin is a class with two methods. This one makes an instance read-only, for a reporting endpoint or a coding agent, by refusing any statement that is not a select:

import {
SelectQueryNode,
type KyselyPlugin,
type PluginTransformQueryArgs,
type PluginTransformResultArgs,
} from 'kysely'

class ReadOnlyPlugin implements KyselyPlugin {
transformQuery({ node }: PluginTransformQueryArgs) {
if (!SelectQueryNode.is(node)) {
throw new Error('This instance is read-only.')
}
return node
}

async transformResult({ result }: PluginTransformResultArgs) {
return result
}
}

const reports = db.withPlugin(new ReadOnlyPlugin())

The plugin reads the statement itself, so an insert, update, delete, or merge is refused whichever helper built it, before anything is sent. So is a statement written whole as a sql template, which reaches it as a raw node.

It is a runtime check, not a type error, and it reads the root node: a select that wraps a data-modifying CTE passes, and finding one takes the exported transformer. A read-only database role is still the boundary, and the plugin reports the mistake earlier, in application code.

Schema-per-tenant shows the difference. A Drizzle table is bound to its schema when it is declared, and the issue titled "working with dynamic schemas is practically impossible" has been open since April 2023. The workaround a maintainer gave there is a factory that declares each table again for a schema name, pgSchema(name).table(...), with every query built from the tables it returns. In Kysely the schema is chosen per query, by a plugin:

const tenantDb = db.withSchema('tenant_1')
// tenantDb.selectFrom('person') compiles to: from "tenant_1"."person"

Drizzle's options cover the cases it names, and Kysely's plugins see the whole query.

Dialects are a public interface​

A Kysely dialect is four exported factories: a driver, a query compiler, an adapter, and an introspector. A new database implements that interface, and the community dialects for BigQuery, ClickHouse, Oracle, and Firebird are built that way. The Database interface and the query API are the same types on every dialect, and do not know which one they target: returning() compiles on a MySQL instance, and MySQL rejects the statement.

Drizzle's dialects ship inside Drizzle, one per database family, each with its own table function, column builders, query builder, and dialect class, and with no shared interface to implement. A new database implements Kysely's dialect interface. In Drizzle it extends a database Drizzle already ships.

Drizzle's dialect families and third-party databases

The families are PostgreSQL, MySQL, SQLite, and SingleStore, plus Gel in 0.45 and MSSQL and CockroachDB in 1.0. Because each has its own builder, its MySQL builder has no returning() to call, and a schema is written per family.

A proxy driver lets you supply the transport for a database Drizzle supports, and does not add one. A third-party database starts from one of the shipped families instead: drizzle-duckdb, "based on drizzle's Postgres client", subclasses Drizzle's PgDialect, PgSession, and PgDatabase.

Migrate from Drizzle to Kysely one query at a time​

A move from Drizzle to Kysely does not have to happen at once, or at all for the schema.

Can Kysely and Drizzle be used together?​

Yes. Keep Drizzle Kit for schema and migrations, and write queries in Kysely against the same database:

  1. Generate Kysely's types from the migrated database, in the migration step or in CI.
  2. Give both clients the same pg pool: drizzle({ client: pool }) and new PostgresDialect({ pool }).
  3. Start with a query that shared functions extend, that traverses JSON, or that declares its types through sql<T>. Move it, inspect .compile(), and compare rows, nulls, ordering, and empty results.
  4. Keep each transaction on one client. The two share a pool, not a transaction.

On Drizzle 0.45.4, the Kyselify type from drizzle-orm/kysely also derives Kysely's table types from the Drizzle table objects, with no generation step. 1.0.0-rc.5 no longer ships that export, so generated types are the path that survives the upgrade.

Other practical considerations​

Schema, migrations, and value conversion

One Drizzle declaration is the source for types and for migrations: Drizzle Kit diffs the tables and generates SQL migrations, and can push to or pull from a database. Kysely has no migration generator. Its migrations are written by hand through the schema builder or any migration tool, and its types are declared or generated from the database, so a migration applied without regenerating them is drift the compiler cannot see.

It also makes the database the source of truth: Kysely queries a schema that another service or migration tool owns, with nothing declared a second time. Drizzle reaches one through drizzle-kit pull, which writes the declarations to keep in step with it.

Drizzle's columns also convert values, through date modes and custom types, and the same tables derive validation schemas through a package per library: Zod, Valibot, TypeBox, and ArkType. Each returns that library's own schema object, and Drizzle has no Standard Schema output of its own. Kysely does neither: its types describe what the driver returns, conversion belongs to the driver's configuration or a plugin, and a validator is written separately.

The table objects also carry runtime behavior. $defaultFn runs when an insert leaves the column out and $onUpdateFn on every update, so an updated_at is set by the builder, and .mapWith() converts one expression's result, which is how count() returns a number.

getTableColumns returns a table's columns as a typed object, so a helper can read any table's columns at runtime, and Drizzle's docs spread it to select every column but password. That selection widens with each column added to the table, the overfetching and drift of select *. Kysely's schema is types only: a default belongs to the database or the caller, a conversion to the driver or a plugin, and a selection names its columns, or selectAll() takes them all, with no exclusion form.

Drivers, runtimes, and caching

Both have zero runtime dependencies and run on Node.js, Deno, Bun, and edge runtimes. Drizzle ships its driver adapters, including Neon, PlanetScale, Cloudflare D1, libSQL, and Bun SQL, inside one package. Kysely's core dialects are PostgreSQL, MySQL, MSSQL, SQLite, and PGlite, and the rest are organization and community dialects, installed separately.

Drizzle also ships a query cache: .$withCache() on one select, or global: true for every select, with an Upstash adapter and an interface for others, and a write invalidates the cached queries that read its tables. Kysely ships no cache. A plugin receives each query and its result, which is where one would be built.

Measured package sizes, bundle sizes, and dependency trees
Published packageCompressed downloadUnpacked sizeDirect dependencies
Kysely 0.29.8319.6 KiB1.66 MiB0
Drizzle 0.45.41,205.7 KiB10.03 MiB0
Drizzle 1.0.0-rc.52,989.0 KiB17.02 MiB0

With pg 8.23.1, all three resolve to 14 production packages. Drizzle's package includes every driver adapter and database family, and Kysely's includes its five core dialects.

A bundler keeps only what an application imports. One PostgreSQL select * from person, over the table at the top of this page, bundled and minified with Vite 8.3.6 and leaving pg out:

Bundled queryMinifiedGzip
Kysely 0.29.8156.3 KiB30.2 KiB
Drizzle 0.45.479.6 KiB20.1 KiB
Drizzle 1.0.0-rc.5137.0 KiB36.4 KiB

Drizzle 1.0.0-rc.5 bundles larger than Kysely, and 0.45.4 makes the smallest bundle. Kysely's includes its query tree, query compiler, and schema builder.

Measured November 9, 2026. Package sizes exclude dependencies, and trees exclude optional dependencies and peer auto-installation. The bundle is one query, not an application, and none of these are install benchmarks.

Choosing Drizzle or Kysely​

Drizzle vs Kysely is a choice between two libraries that both put SQL first. What differs is how much of the query the compiler checks, and what else the library does.

Why use Kysely instead of Drizzle?​

Choose Kysely when queries are composed, shared, and changed, and you want TypeScript to check each change.

Its types come from the database itself, so it fits a schema that another tool, service, or team migrates. Immich, Open Collective, and Docmost compose production queries with it.

When is Drizzle the better choice?​

Keep Drizzle when a person writes the schema and the queries, one TypeScript declaration should drive types, migrations, validation schemas, and relational reads, and the queries are mostly written whole, in one place. A schema change is one edit there: drizzle-kit push applies it to the database without a migration file, and the types, validators, and relational queries follow with no generation step.

Chosen for its builder alone, Drizzle trades the checks on this page for column objects an editor can rename, a query cache, and nested callback transactions.

That value is priced for a person writing the code. When an agent writes most of it, it writes a migration as SQL as readily as a query, inspects the database through psql or a script instead of a GUI, and already knows SQL, where a model API's options are a second vocabulary per library. OpenClaw's contributor guide, written for agents, draws that line: schema and migrations as SQL, queries in Kysely. Drizzle Kit can stay either way, and Kysely can run beside it for the queries that are composed.

Is Drizzle an ORM or a query builder?​

Drizzle is an ORM in the data framework sense: models, generated migrations, relational reads, and a query builder. Its tables are models under another name: each maps property names to columns and converts values, with relations declared beside it. This page compares its builder with Kysely, which is a query builder only. SQL query builder vs ORM covers model APIs like Drizzle's relational queries.

Is Drizzle type-safe?​

Yes for values and results, and no for scope. Drizzle infers each row and checks a literal against its column. It does not check that a column belongs to the query: a column object is accepted in any query. Kysely checks that too, with gaps of its own.

Is Kysely faster than Drizzle?​

The database cannot tell them apart: both send one parameterized statement, and performance belongs to the statement. Drizzle describes itself as "a thin TypeScript layer on top of SQL with almost 0 overhead", and its prepared statements build the SQL once, with sql.placeholder() for the values that change, and reuse it on every call. Kysely has no placeholder API: it builds the statement on each call, and .compile() splits building from execution only for a query whose values are fixed. Neither library is benchmarked here.

Is Kysely smaller than Drizzle?​

Smaller than Drizzle 1.0 in the bundle and as a package, and smaller than stable Drizzle as a package only. One PostgreSQL query bundles to 36.4 KiB gzipped with Drizzle 1.0.0-rc.5 and 30.2 KiB with Kysely 0.29.8, and Drizzle 0.45.4 bundles to 20.1 KiB. Kysely's published package is a 319.6 KiB download, against 2,989.0 KiB for the release candidate, more than nine times the size, and 1,205.7 KiB for 0.45.4. The measurements and what they exclude are above.

Which is easier for a coding agent to use?​

Both ship examples where an agent reads them, and Kysely checks more of what the agent writes. An agent works by editing and running the compiler again, and the checks on this page sit inside that loop: a wrong reference or a misspelled JSON key is a compile error it can read and fix without a database, where Drizzle gives it a failed run or a silent null.

Drizzle's team kept its API order to spare developers "huge type errors that are quite hard to understand", a trade priced for a person reading the error. An agent reads a long type error in the same loop as a short one, and before any query runs. What no loop reports is a null that arrives under a string type. Each library documents its methods in the type declarations it installs, and each site publishes an llms.txt. Kysely's inline examples are type-checked in CI against the release they ship with.

Try one shared query in each library: a filter that a second function extends, a JSON key it reads, and a join it later drops. Compare what the compiler reports before anything runs, and what the first request reports after.

Both put SQL first. Drizzle types the columns a query uses, and Kysely types the columns and the query.

Get started with Kysely, find equivalents in the query examples, or compare query builders with ORMs.

Adoption: replacing Drizzle with Kysely

OpenClaw's creator, Peter Steinberger, described replacing Drizzle with Kysely in one of his projects. OpenClaw's contributor guide requires Kysely for ordinary SQLite access, reserving raw SQL for schema, migrations, bootstrap, and justified primitives.