Drizzle vs Kysely in 2026: SQL-First ORMs Compared
TL;DR
Drizzle if you need schema definition + migrations; Kysely if you want a pure query builder over an existing schema. Drizzle (~2M weekly downloads) is a full ORM — define schema in TypeScript, generate migrations, query with type safety, and deploy to edge runtimes. Kysely (~800K downloads) is a type-safe SQL query builder — it generates excellent TypeScript types from an existing database schema but doesn't manage migrations or schema evolution. Both produce idiomatic SQL with no hidden N+1 queries or magic abstractions.
Key Takeaways
- Drizzle: ~2M weekly downloads — Kysely: ~800K (npm, March 2026)
- Drizzle is an ORM + migration tool — schema definition,
drizzle-kit, Drizzle Studio included - Kysely is a query builder only — no schema management, relies on external migration tools
- Both are SQL-first — you write TypeScript that reads like SQL; no hidden query generation
- Drizzle has native edge support — built-in drivers for Cloudflare Workers, Vercel Edge, Bun, Deno
- Kysely is excellent for brownfield — introspect an existing database with
kysely-codegenand get types immediately
Philosophy: ORM vs Query Builder
The distinction between Drizzle and Kysely is architectural, not cosmetic. Drizzle is a full ORM in the sense that it owns the entire database workflow: you define your schema in TypeScript, run drizzle-kit generate to produce migration SQL, and Drizzle tracks which migrations have run. The schema definition file is the single source of truth for both your TypeScript types and your database structure.
Kysely's scope is intentionally narrower. It is a query builder — a library that gives you TypeScript types and a fluent API for constructing SQL queries. It does not generate migrations, does not own schema evolution, and does not provide a schema definition DSL. You bring your own types (either hand-written or generated from the database via kysely-codegen) and Kysely ensures your queries type-check against those types.
Neither approach is wrong. The choice depends on whether you're starting a new project (where Drizzle's schema-first workflow saves significant time) or working with an existing database where schema changes are managed by another tool (where Kysely's query-builder focus is a feature, not a limitation).
Drizzle covers:
schema definition (TypeScript)
migration generation (drizzle-kit)
migration application (drizzle-kit push / migrate)
type-safe queries
relations (explicit relation definitions for joins)
edge runtime support (Cloudflare Workers, Vercel Edge, Bun, Deno)
Drizzle Studio (GUI for your database)
multiple databases (PostgreSQL, MySQL, SQLite, libSQL)
Kysely covers:
type-safe queries
database introspection / type generation (kysely-codegen)
plugin / dialect system (PostgreSQL, MySQL, SQLite, custom)
composable query fragments
raw SQL escape hatches
Kysely does NOT cover:
schema definition DSL
migration management (use db-migrate, Flyway, raw SQL files, or kysely-migration-cli)
Schema Definition: Drizzle's Advantage for New Projects
For greenfield projects, Drizzle's TypeScript schema is one of its most compelling features. You write your entire database structure in TypeScript, and Drizzle knows how to translate that into SQL for your target database.
// Drizzle — define schema in TypeScript
import {
pgTable, serial, text, integer, timestamp, boolean, uuid, pgEnum
} from 'drizzle-orm/pg-core';
export const roleEnum = pgEnum('user_role', ['admin', 'user', 'moderator']);
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
role: roleEnum('role').default('user').notNull(),
emailVerified: boolean('email_verified').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content'),
slug: text('slug').notNull().unique(),
authorId: uuid('author_id').references(() => users.id, { onDelete: 'cascade' }),
published: boolean('published').default(false),
publishedAt: timestamp('published_at', { withTimezone: true }),
});
// npx drizzle-kit generate → creates SQL migration file
// npx drizzle-kit push → applies directly (good for dev)
// npx drizzle-kit migrate → runs tracked migrations (good for prod)
Kysely's types come from the database, not to it. You use kysely-codegen to introspect an existing database and generate TypeScript interfaces:
// Terminal: npx kysely-codegen --url postgres://user:pass@localhost/mydb
// Generates a file like:
// db.d.ts (generated — don't edit manually)
import type { ColumnType, Generated } from 'kysely';
export type UserRole = 'admin' | 'user' | 'moderator';
export interface UsersTable {
id: Generated<string>; // uuid with default
email: string;
name: string;
role: UserRole;
email_verified: Generated<boolean>;
created_at: Generated<Date>;
updated_at: Generated<Date>;
}
export interface PostsTable {
id: Generated<number>;
title: string;
content: string | null;
slug: string;
author_id: string | null;
published: Generated<boolean>;
published_at: Date | null;
}
export interface Database {
users: UsersTable;
posts: PostsTable;
}
With Drizzle, the TypeScript is the schema. With Kysely, the database is the schema and TypeScript describes it. Both give you type safety; the direction of truth is opposite.
Migration Management
Drizzle includes migration tooling via drizzle-kit. The workflow is: edit your schema TypeScript file, run drizzle-kit generate to produce a numbered SQL migration file, and run drizzle-kit migrate to apply it. You can check migration files into version control, which gives you a complete history of schema changes.
# Drizzle migration workflow
npx drizzle-kit generate # creates migrations/0001_create_users.sql
npx drizzle-kit migrate # applies pending migrations
npx drizzle-kit studio # opens Drizzle Studio GUI
# Drizzle also supports push for development:
npx drizzle-kit push # sync schema directly (skips migration files)
-- Example generated migration (migrations/0001_create_users.sql)
CREATE TYPE "user_role" AS ENUM('admin', 'user', 'moderator');
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"email" text NOT NULL UNIQUE,
"name" text NOT NULL,
"role" "user_role" DEFAULT 'user' NOT NULL,
"email_verified" boolean DEFAULT false,
"created_at" timestamptz DEFAULT now(),
"updated_at" timestamptz DEFAULT now()
);
Kysely handles this differently — by not handling it at all within the library itself. Teams typically use one of:
kysely-migration-cli— a separate npm package that runs JavaScript/TypeScript migration files with Kysely's migration API- Flyway or Liquibase — Java-based migration tools common in enterprise environments
- Raw SQL files + a custom runner
- db-migrate — database-agnostic migration framework
// Kysely migration using the built-in migration API
import { Migrator, FileMigrationProvider } from 'kysely';
import path from 'path';
import fs from 'fs/promises';
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(__dirname, 'migrations'),
}),
});
// migrations/001_create_users.ts
import type { Kysely } from 'kysely';
export async function up(db: Kysely<any>) {
await db.schema
.createTable('users')
.addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn('email', 'text', col => col.notNull().unique())
.addColumn('name', 'text', col => col.notNull())
.execute();
}
export async function down(db: Kysely<any>) {
await db.schema.dropTable('users').execute();
}
Kysely's migration API is functional once configured, but you write the migration logic yourself. Drizzle's generate command analyzes the diff between your schema and the last migration and produces the SQL automatically.
Query Building Syntax
Both libraries produce SQL-like TypeScript queries. The syntax difference is subtle but meaningful in practice.
// Drizzle — operators are imported functions, schema provides type info
import { db } from './db';
import { users, posts } from './schema';
import { eq, desc, and, like, count, sql } from 'drizzle-orm';
// SELECT with filters
const admins = await db
.select()
.from(users)
.where(and(
eq(users.role, 'admin'),
like(users.email, '%@company.com')
))
.orderBy(desc(users.createdAt))
.limit(10);
// admins: Array<{ id: string; email: string; name: string; role: "admin" | "user" | "moderator"; ... }>
// JOIN with column selection
const postsWithAuthors = await db
.select({
postTitle: posts.title,
postSlug: posts.slug,
authorName: users.name,
authorEmail: users.email,
})
.from(posts)
.leftJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.orderBy(desc(posts.publishedAt));
// Aggregate
const [{ postCount }] = await db
.select({ postCount: count(posts.id) })
.from(posts)
.where(eq(posts.authorId, userId));
// Kysely — SQL-column-name based, types from Database interface
import { db } from './db';
// SELECT with filters
const admins = await db
.selectFrom('users')
.selectAll()
.where('role', '=', 'admin')
.where('email', 'like', '%@company.com')
.orderBy('created_at', 'desc')
.limit(10)
.execute();
// admins: Array<Selectable<UsersTable>>
// JOIN with column selection
const postsWithAuthors = await db
.selectFrom('posts')
.leftJoin('users', 'users.id', 'posts.author_id')
.select([
'posts.title as postTitle',
'posts.slug as postSlug',
'users.name as authorName',
'users.email as authorEmail',
])
.where('posts.published', '=', true)
.orderBy('posts.published_at', 'desc')
.execute();
// Aggregate
const { post_count } = await db
.selectFrom('posts')
.select(db.fn.count('id').as('post_count'))
.where('author_id', '=', userId)
.executeTakeFirstOrThrow();
Drizzle's approach imports operators as functions (eq, and, like) which provides excellent autocomplete and catches type errors at compile time. Kysely's string-based column names ('posts.published') are also type-checked — Kysely knows which columns exist and will reject unknown column names.
Edge Runtime and Serverless
Drizzle was designed with edge runtimes in mind. It ships drivers for Cloudflare Workers (using the D1 and Hyperdrive bindings), Vercel Edge with Neon, PlanetScale's HTTP driver, Turso (libSQL), and more.
// Drizzle + Cloudflare Workers + D1
import { drizzle } from 'drizzle-orm/d1';
import { users } from './schema';
export default {
async fetch(request: Request, env: Env) {
const db = drizzle(env.DB); // D1 binding
const allUsers = await db.select().from(users).all();
return Response.json(allUsers);
},
};
// Drizzle + Vercel Edge + Neon
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
const sql = neon(process.env.DATABASE_URL!);
const db = drizzle(sql);
Kysely supports edge runtimes through its dialect system, but requires more configuration. The community has built dialects for various edge databases:
// Kysely + PlanetScale serverless driver
import { Kysely } from 'kysely';
import { PlanetScaleDialect } from 'kysely-planetscale';
const db = new Kysely<Database>({
dialect: new PlanetScaleDialect({
url: process.env.DATABASE_URL,
}),
});
Raw SQL Escape Hatches
Both libraries make it easy to drop into raw SQL when you need features beyond the query builder's scope.
// Drizzle — sql template tag
import { sql } from 'drizzle-orm';
// Inline expression in a query
const result = await db.select({
id: users.id,
postCount: sql<number>`count(${posts.id})::int`,
}).from(users)
.leftJoin(posts, eq(posts.authorId, users.id))
.groupBy(users.id);
// Full raw query
const rows = await db.execute(
sql`SELECT * FROM users WHERE created_at > NOW() - INTERVAL '7 days'`
);
// Kysely — sql template tag
import { sql } from 'kysely';
const result = await db
.selectFrom('users')
.select([
'users.id',
sql<number>`count(posts.id)::int`.as('post_count'),
])
.leftJoin('posts', 'posts.author_id', 'users.id')
.groupBy('users.id')
.execute();
Package Health
| Package | Weekly Downloads | Bundle Size (gzip) | Last Release | Maintained |
|---|---|---|---|---|
| drizzle-orm | ~2M | ~35KB | Active (v0.42.x) | Yes — Drizzle team |
| kysely | ~800K | ~30KB | Active (v0.27.x) | Yes — @koskimas |
Both packages are actively maintained with regular releases. Drizzle is growing faster from a smaller base. Kysely is more stable and has been around longer.
When to Choose
Choose Drizzle when:
- Starting a new project — schema definition and migration generation save significant setup time
- You want TypeScript-first database management from schema to query
- Using edge runtimes (Cloudflare Workers, Vercel Edge, Bun) — Drizzle has built-in driver support
- You want Drizzle Studio for a GUI over your database during development
- Team wants a single tool that covers schema, migrations, and queries
- Building with Turso, libSQL, PlanetScale, or other modern serverless databases
Choose Kysely when:
- Working with an existing database that already has migrations managed elsewhere
- Your team uses Flyway, Liquibase, or SQL-based migrations and doesn't want to change
- You want a drop-in type-safe wrapper over raw
pgormysql2queries - Maximum SQL control with minimum abstraction overhead
- Your database schema is large and complex — introspecting it with
kysely-codegenis faster than rewriting in Drizzle schema syntax - You're on a team that values the query-builder-only mental model
Compare Drizzle and Kysely download trends and release history on the Drizzle vs Kysely comparison on PkgPulse.
For validation of data coming into your database layer, see Zod vs TypeBox — the same TypeScript-first philosophy applied to schema validation. If your project uses Next.js with a database, see Passport vs NextAuth for the auth layer decision.
Browse Drizzle ORM package details and Kysely package details on PkgPulse.
See the live comparison
View drizzle vs. kysely on PkgPulse →