| name | kysely-orm |
| description | Kysely ORM patterns for type-safe SQL query building, migrations, and TypeScript integration. Use when working with Kysely, writing type-safe queries, or managing database schemas with Kysely migrations. |
| metadata | {"triggers":{"patterns":["**/kysely/**","**/db/index.ts","**/database.ts"],"keywords":["kysely","Kysely","selectFrom","insertInto","Generated<","Selectable<","Insertable<"]}} |
Kysely ORM
Type-safe TypeScript SQL query builder. This skill covers Kysely-specific patterns—for database-specific syntax (PostgreSQL, SQL Server, MySQL), see the corresponding database skills.
When to Use Kysely vs Drizzle
| Aspect | Kysely | Drizzle |
|---|
| Philosophy | Pure query builder with typing | Full ORM with schema-first approach |
| Migration tooling | Manual up/down functions | Auto-generated with drizzle-kit |
| Best for | Knex.js migrations, complex queries | New projects, integrated tooling |
| Nested queries | Built-in helpers (jsonArrayFrom) | Manual grouping required |
| Learning curve | Familiar if you know SQL/Knex | ORM concepts required |
Choose Kysely when:
- Migrating from Knex.js
- You want SQL-like syntax with full type safety
- You need complex nested queries with JSON aggregation
- You prefer writing migrations manually
Project Setup
Installation
Use the project's package manager (detect from lock file: bun.lockb, pnpm-lock.yaml, yarn.lock, or package-lock.json).
<pkg> add kysely
<pkg> add pg
<pkg> add mysql2
<pkg> add better-sqlite3
<pkg> add tedious
Database Instance
import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';
import { Database } from './types';
export const db = new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({
connectionString: process.env.DATABASE_URL,
}),
}),
});
process.on('SIGTERM', async () => {
await db.destroy();
});
SQL Server Setup
import { Kysely, MssqlDialect } from 'kysely';
import * as Tedious from 'tedious';
import * as Tarn from 'tarn';
export const db = new Kysely<Database>({
dialect: new MssqlDialect({
tarn: {
...Tarn,
options: {
min: 0,
max: 10,
},
},
tedious: {
...Tedious,
connectionFactory: () =>
new Tedious.Connection({
server: process.env.DB_HOST,
authentication: {
type: 'default',
options: {
userName: process.env.DB_USER,
password: process.env.DB_PASSWORD,
},
},
options: {
database: process.env.DB_NAME,
encrypt: true,
trustServerCertificate: true,
},
}),
},
}),
});
Database Types
Type Generation (Recommended)
For production apps, generate types from your database:
<pkg> add -D kysely-codegen
npx kysely-codegen --out-file ./db/types.ts
Manual Type Definition
import { Generated, ColumnType, Selectable, Insertable, Updateable } from 'kysely';
export interface Database {
users: UsersTable;
posts: PostsTable;
comments: CommentsTable;
}
export interface UsersTable {
id: Generated<string>;
email: string;
name: string;
password_hash: string;
is_active: boolean;
metadata: Generated<unknown>;
created_at: Generated<Date>;
updated_at: Generated<Date>;
deleted_at: Date | null;
}
export type User = Selectable<UsersTable>;
export type NewUser = Insertable<UsersTable>;
export type UserUpdate = Updateable<UsersTable>;
Type Wrappers Explained
| Wrapper | Use For | Generated Columns? | Nullable? |
|---|
Generated<T> | Columns with DEFAULT or auto-generated | Yes | Based on T |
Selectable<Table> | Query results | Unwraps Generated | Preserves |
Insertable<Table> | INSERT operations | Makes optional | Preserves |
Updateable<Table> | UPDATE operations | Makes optional | Makes optional |
export interface PostsTable {
id: Generated<string>;
created_at: Generated<Date>;
user_id: string;
title: string;
content: string | null;
published_at: Date | null;
view_count: Generated<number | null>;
}
Query Building
CRITICAL: Queries Are Immutable
let query = db.selectFrom('users');
query.where('id', '=', userId);
let query = db.selectFrom('users');
query = query.where('id', '=', userId);
const user = await db
.selectFrom('users')
.where('id', '=', userId)
.selectAll()
.executeTakeFirst();
SELECT Queries
const users = await db
.selectFrom('users')
.selectAll()
.execute();
const users = await db
.selectFrom('users')
.select(['id', 'email', 'name'])
.execute();
const activeUsers = await db
.selectFrom('users')
.where('is_active', '=', true)
.where('created_at', '>', new Date('2024-01-01'))
.selectAll()
.execute();
const user = await db
.selectFrom('users')
.where('id', '=', userId)
.selectAll()
.executeTakeFirst();
const user = await db
.selectFrom('users')
.where('id', '=', userId)
.selectAll()
.executeTakeFirstOrThrow();
INSERT Queries
const result = await db
.insertInto('users')
.values({
email: 'user@example.com',
name: 'John Doe',
password_hash: hashedPassword,
is_active: true,
})
.returningAll()
.executeTakeFirstOrThrow();
await db
.insertInto('tags')
.values([
{ name: 'typescript' },
{ name: 'kysely' },
{ name: 'database' },
])
.execute();
await db
.insertInto('users')
.values({ email, name, password_hash })
.onConflict((oc) =>
oc.column('email').doUpdateSet({ name, updated_at: new Date() })
)
.execute();
UPDATE Queries
const updated = await db
.updateTable('users')
.set({ is_active: false, updated_at: new Date() })
.where('id', '=', userId)
.returningAll()
.executeTakeFirst();
await db
.updateTable('posts')
.set({ status: 'published', published_at: new Date() })
.where('id', '=', postId)
.where('user_id', '=', userId)
.execute();
DELETE Queries
await db
.deleteFrom('sessions')
.where('expires_at', '<', new Date())
.execute();
const deleted = await db
.deleteFrom('users')
.where('id', '=', userId)
.returningAll()
.executeTakeFirst();
Joins
const postsWithAuthors = await db
.selectFrom('posts')
.innerJoin('users', 'users.id', 'posts.user_id')
.select([
'posts.id',
'posts.title',
'users.name as author_name',
])
.execute();
const usersWithPosts = await db
.selectFrom('users')
.leftJoin('posts', 'posts.user_id', 'users.id')
.select([
'users.id',
'users.name',
'posts.title',
])
.execute();
const result = await db
.selectFrom('posts')
.innerJoin('users', 'users.id', 'posts.user_id')
.leftJoin('categories', 'categories.id', 'posts.category_id')
.select([
'posts.id',
'posts.title',
'users.name as author',
'categories.name as category',
])
.execute();
JSON Aggregation (Nested Queries)
Kysely's killer feature for complex nested data:
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
const postsWithComments = await db
.selectFrom('posts')
.select((eb) => [
'posts.id',
'posts.title',
jsonArrayFrom(
eb.selectFrom('comments')
.select(['comments.id', 'comments.content', 'comments.created_at'])
.whereRef('comments.post_id', '=', 'posts.id')
.orderBy('comments.created_at', 'desc')
).as('comments'),
])
.execute();
const postWithAuthor = await db
.selectFrom('posts')
.select((eb) => [
'posts.id',
'posts.title',
'posts.content',
jsonObjectFrom(
eb.selectFrom('users')
.select(['users.id', 'users.name', 'users.email'])
.whereRef('users.id', '=', 'posts.user_id')
).as('author'),
])
.where('posts.id', '=', postId)
.executeTakeFirst();
Transactions
const result = await db.transaction().execute(async (trx) => {
const user = await trx
.insertInto('users')
.values({ email, name, password_hash })
.returningAll()
.executeTakeFirstOrThrow();
await trx
.insertInto('user_settings')
.values({ user_id: user.id, theme: 'light' })
.execute();
return user;
});
import { sql } from 'kysely';
await db.transaction().execute(async (trx) => {
await sql`SET TRANSACTION ISOLATION LEVEL SERIALIZABLE`.execute(trx);
});
Raw SQL
import { sql } from 'kysely';
const result = await db
.selectFrom('users')
.select([
'id',
'name',
sql<number>`extract(year from created_at)`.as('join_year'),
])
.execute();
const users = await db
.selectFrom('users')
.where(sql`lower(email)`, '=', email.toLowerCase())
.selectAll()
.execute();
const result = await sql<{ count: number }>`
SELECT count(*) as count FROM users WHERE is_active = true
`.execute(db);
const searchTerm = 'john';
const users = await sql<User>`
SELECT * FROM users
WHERE name ILIKE ${`%${searchTerm}%`}
`.execute(db);
Migrations
Migration File Structure
migrations/
├── 001_create_users.ts
├── 002_create_posts.ts
├── 003_add_email_index.ts
└── 004_add_user_settings.ts
Migration Template
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('users')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`)
)
.addColumn('email', 'varchar(255)', (col) => col.notNull().unique())
.addColumn('name', 'varchar(100)', (col) => col.notNull())
.addColumn('password_hash', 'varchar(255)', (col) => col.notNull())
.addColumn('is_active', 'boolean', (col) => col.notNull().defaultTo(true))
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`)
)
.addColumn('updated_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`)
)
.execute();
await db.schema
.createIndex('idx_users_email')
.on('users')
.column('email')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('users').execute();
}
Running Migrations
import { promises as fs } from 'fs';
import path from 'path';
import { Migrator, FileMigrationProvider } from 'kysely';
import { db } from './db';
async function migrate() {
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(__dirname, 'migrations'),
}),
});
const { error, results } = await migrator.migrateToLatest();
results?.forEach((it) => {
if (it.status === 'Success') {
console.log(`Migration "${it.migrationName}" executed successfully`);
} else if (it.status === 'Error') {
console.error(`Migration "${it.migrationName}" failed`);
}
});
if (error) {
console.error('Migration failed');
console.error(error);
process.exit(1);
}
await db.destroy();
}
migrate();
Schema Builder Reference
await db.schema
.createTable('posts')
.addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`))
.addColumn('user_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade')
)
.addColumn('title', 'varchar(200)', (col) => col.notNull())
.addColumn('content', 'text')
.addColumn('status', sql`post_status`, (col) => col.notNull().defaultTo('draft'))
.addColumn('view_count', 'integer', (col) => col.notNull().defaultTo(0))
.addColumn('metadata', 'jsonb', (col) => col.defaultTo(sql`'{}'::jsonb`))
.addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
.execute();
await db.schema
.alterTable('users')
.addColumn('phone', 'varchar(20)')
.execute();
await db.schema
.alterTable('users')
.dropColumn('phone')
.execute();
await db.schema
.alterTable('users')
.renameColumn('name', 'full_name')
.execute();
await db.schema
.createIndex('idx_posts_user_id')
.on('posts')
.column('user_id')
.execute();
await db.schema
.createIndex('idx_users_email')
.on('users')
.column('email')
.unique()
.execute();
await db.schema.dropIndex('idx_posts_user_id').execute();
await sql`
CREATE INDEX idx_posts_published
ON posts(published_at)
WHERE status = 'published'
`.execute(db);
Plugins
CamelCase Plugin
Automatically convert between snake_case (DB) and camelCase (TypeScript):
import { Kysely, PostgresDialect, CamelCasePlugin } from 'kysely';
const db = new Kysely<Database>({
dialect: new PostgresDialect({ pool }),
plugins: [new CamelCasePlugin()],
});
const user = await db
.selectFrom('users')
.select(['id', 'createdAt', 'isActive'])
.executeTakeFirst();
Dedupe Join Plugin
Prevents duplicate joins in complex queries:
import { DeduplicateJoinsPlugin } from 'kysely';
const db = new Kysely<Database>({
dialect,
plugins: [new DeduplicateJoinsPlugin()],
});
Common Patterns
Repository Pattern
import { db } from '../db';
import { NewUser, User, UserUpdate } from '../db/types';
export const userRepository = {
async findById(id: string): Promise<User | undefined> {
return db
.selectFrom('users')
.where('id', '=', id)
.selectAll()
.executeTakeFirst();
},
async findByEmail(email: string): Promise<User | undefined> {
return db
.selectFrom('users')
.where('email', '=', email)
.selectAll()
.executeTakeFirst();
},
async create(user: NewUser): Promise<User> {
return db
.insertInto('users')
.values(user)
.returningAll()
.executeTakeFirstOrThrow();
},
async update(id: string, data: UserUpdate): Promise<User | undefined> {
return db
.updateTable('users')
.set({ ...data, updated_at: new Date() })
.where('id', '=', id)
.returningAll()
.executeTakeFirst();
},
async delete(id: string): Promise<boolean> {
const result = await db
.deleteFrom('users')
.where('id', '=', id)
.executeTakeFirst();
return result.numDeletedRows > 0n;
},
};
Dynamic Query Building
interface UserFilters {
email?: string;
isActive?: boolean;
createdAfter?: Date;
search?: string;
}
async function findUsers(filters: UserFilters): Promise<User[]> {
let query = db.selectFrom('users').selectAll();
if (filters.email) {
query = query.where('email', '=', filters.email);
}
if (filters.isActive !== undefined) {
query = query.where('is_active', '=', filters.isActive);
}
if (filters.createdAfter) {
query = query.where('created_at', '>', filters.createdAfter);
}
if (filters.search) {
query = query.where((eb) =>
eb.or([
eb('name', 'ilike', `%${filters.search}%`),
eb('email', 'ilike', `%${filters.search}%`),
])
);
}
return query.execute();
}
Anti-Patterns
| Anti-Pattern | Problem | Solution |
|---|
| Mutating queries | query.where() returns new query | Always reassign: query = query.where() |
| Using table types directly | Breaks on Generated columns | Use Selectable<T>, Insertable<T> |
Missing Generated<> | Required fields become optional | Mark DB-generated columns |
| Not destroying db | Connection pool leaks | Call db.destroy() on shutdown |
any in migrations | Loses type safety benefits | Accept it—migration types are dynamic |
| Raw SQL without params | SQL injection risk | Use sql template: sql\...${param}`` |
Quick Reference
TYPES:
Database Interface mapping table names to types
Generated<T> Column with DEFAULT or auto-generated
Selectable<T> Type for SELECT results
Insertable<T> Type for INSERT values
Updateable<T> Type for UPDATE values
QUERIES:
selectFrom() Start SELECT query
insertInto() Start INSERT query
updateTable() Start UPDATE query
deleteFrom() Start DELETE query
EXECUTION:
execute() Returns array
executeTakeFirst() Returns single or undefined
executeTakeFirstOrThrow() Returns single or throws
JOINS:
innerJoin() INNER JOIN
leftJoin() LEFT JOIN
rightJoin() RIGHT JOIN
fullJoin() FULL OUTER JOIN
HELPERS:
jsonArrayFrom() Nested array aggregation
jsonObjectFrom() Nested object
sql`` Raw SQL template
SCHEMA:
createTable() CREATE TABLE
alterTable() ALTER TABLE
dropTable() DROP TABLE
createIndex() CREATE INDEX
References