
Introduction 🎯
Most TypeScript backend developers face a familiar dilemma: use an ORM and lose fine-grained SQL control, or write raw SQL and lose type safety. Kysely eliminates that trade-off entirely.
Kysely is a type-safe SQL query builder for TypeScript and Node.js. It’s not an ORM — it doesn’t manage entities, relationships, or migrations. Instead, it gives you a fluent, fully type-safe API to construct any SQL query while catching errors at compile time.
// This compiles — types are correct
const result = await db
.selectFrom('users')
.innerJoin('posts', 'posts.author_id', 'users.id')
.select(['users.name', 'posts.title'])
.where('users.active', '=', true)
.execute()
// This fails at COMPILE TIME — 'nmae' doesn't exist on users table
const bad = await db
.selectFrom('users')
.select(['nmae']) // ❌ TypeScript error: Property 'nmae' does not exist
If you’ve everdebugged a production SQL typo at 3 AM, this article is for you. We’ll build real queries, understand the type system, and see why Kysely is becoming the go-to choice for TypeScript developers who want SQL power without the risk.
Let’s build. 🚀
Part 1: Why Type-Safe Query Builders?
1.1 The ORM Problem
ORMs like TypeORM, Prisma, and Sequelize abstract away SQL. That’s great for simple CRUD, but problematic when you need:
- Complex JOINs across 5+ tables
- Window functions, CTEs, or database-specific syntax
- Full control over query plan and indexing
- Subqueries that would be awkward to express in ORM methods
ORMs also introduce a leaky abstraction — you still need to understand SQL to debug performance issues, but now you’re debugging through another layer.
1.2 The Raw SQL Problem
Raw SQL gives you full power but introduces:
- No type safety: A typo in a column name is a runtime error, not a compile-time error
- String concatenation: SQL injection risks if you’re not careful
- No IDE support: No autocomplete, no refactoring, no go-to-definition
- Migration headaches: Schema changes require manual updates everywhere
1.3 The Kysely Solution
Kysely sits in the sweet spot:
| Feature | ORM | Raw SQL | Kysely |
|---|---|---|---|
| Type safety | ✅ Entities | ❌ None | ✅ Full SQL |
| SQL power | ⚠️ Limited | ✅ Full | ✅ Full |
| Performance control | ⚠️ Abstraction | ✅ Direct | ✅ Direct |
| IDE support | ✅ Good | ❌ Minimal | ✅ Excellent |
| Learning curve | 📈 High | 📉 Low | 📊 Medium |
| Runtime overhead | 📈 High | 📉 None | 📉 Minimal |
Part 2: Setup & Configuration
2.1 Installation
# PostgreSQL (recommended)
npm install kysely pg @types/pg
# MySQL
npm install kysely mysql2
# SQLite
npm install kysely better-sqlite3
# For TypeScript type generation from existing DB
npm install -D kysely-codegen
2.2 Database Connection
import { Kysely, PostgresDialect } from 'kysely'
import { Pool } from 'pg'
const db = new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({
host: 'localhost',
port: 5432,
database: 'myapp',
user: 'postgres',
password: 'password',
}),
}),
})
For MySQL:
import { Kysely, MysqlDialect } from 'kysely'
import { createPool } from 'mysql2'
const db = new Kysely<Database>({
dialect: new MysqlDialect({
pool: createPool({
host: 'localhost',
port: 3306,
database: 'myapp',
user: 'root',
password: 'password',
}),
}),
})
2.3 Table Definitions (The Key to Type Safety)
This is where Kysely’s magic happens. You define TypeScript interfaces that mirror your database schema:
import {
ColumnType,
Generated,
JSONColumnType,
Simplify,
} from 'kysely'
// Generated<T> = auto-incrementing column
// ColumnType< selectType, insertType, updateType > = custom per-operation types
export interface Database {
users: UsersTable
posts: PostsTable
comments: CommentsTable
tags: TagsTable
post_tags: PostTagsTable
}
export interface UsersTable {
id: Generated<number>
username: string
email: string
password_hash: string
bio: string | null
avatar_url: string | null
role: 'admin' | 'user' | 'moderator'
metadata: JSONColumnType<UserMetadata>
created_at: ColumnType<Date, string | Date, never> // never = not updatable
updated_at: ColumnType<Date, string | Date, string | Date>
}
export interface UserMetadata {
login_count: number
last_ip: string | null
preferences: {
theme: 'light' | 'dark'
notifications: boolean
}
}
export interface PostsTable {
id: Generated<number>
title: string
slug: string
content: string
excerpt: string | null
author_id: number
status: 'draft' | 'published' | 'archived'
published_at: Date | null
view_count: ColumnType<number, number, number> // manually managed
created_at: ColumnType<Date, string | Date, never>
updated_at: ColumnType<Date, string | Date, string | Date>
}
export interface CommentsTable {
id: Generated<number>
content: string
post_id: number
author_id: number
parent_id: number | null // for nested replies
created_at: ColumnType<Date, string | Date, never>
}
export interface TagsTable {
id: Generated<number>
name: string
slug: string
created_at: ColumnType<Date, string | Date, never>
}
export interface PostTagsTable {
post_id: number
tag_id: number
}
Key concepts:
Generated<T>: Column auto-generated by the database (e.g.,SERIAL,AUTO_INCREMENT)ColumnType<Select, Insert, Update>: Different types for different operationsJSONColumnType<T>: Automatically serializes/deserializes JSONnull: Nullable column (e.g.,bio: string | null)
Part 3: SELECT Queries
3.1 Basic SELECT
// Select all columns — returns UsersTable[]
const allUsers = await db
.selectFrom('users')
.selectAll()
.execute()
// Select specific columns — returns { name: string; email: string }[]
const userBasic = await db
.selectFrom('users')
.select(['username', 'email'])
.execute()
// Select with alias
const userNames = await db
.selectFrom('users')
.select((eb) => [
'users.username as name',
'users.email as contact',
])
.execute()
3.2 WHERE Clauses
// Simple equality
const activeUsers = await db
.selectFrom('users')
.selectAll()
.where('role', '=', 'admin')
.execute()
// Comparison operators
const popularPosts = await db
.selectFrom('posts')
.select(['id', 'title', 'view_count'])
.where('view_count', '>', 1000)
.where('status', '=', 'published')
.execute()
// IN clause
const specificUsers = await db
.selectFrom('users')
.selectAll()
.where('id', 'in', [1, 2, 3, 4, 5])
.execute()
// LIKE / ILIKE (PostgreSQL case-insensitive)
const searchResults = await db
.selectFrom('posts')
.select(['id', 'title', 'excerpt'])
.where('title', 'ilike', '%typescript%')
.execute()
// IS NULL / IS NOT NULL
const usersWithBio = await db
.selectFrom('users')
.select(['id', 'username', 'bio'])
.where('bio', 'is not', null)
.execute()
// BETWEEN
const recentPosts = await db
.selectFrom('posts')
.selectAll()
.where('created_at', '>=', new Date('2026-01-01'))
.where('created_at', '<', new Date('2026-02-01'))
.execute()
3.3 AND / OR Conditions
// AND (implicit — multiple .where() calls)
const result = await db
.selectFrom('posts')
.selectAll()
.where('status', '=', 'published')
.where('author_id', '=', 1)
.execute()
// SQL: WHERE status = 'published' AND author_id = 1
// OR (explicit with eb.or())
const result2 = await db
.selectFrom('posts')
.selectAll()
.where((eb) =>
eb.or([
eb('status', '=', 'draft'),
eb('status', '=', 'published'),
])
)
.execute()
// SQL: WHERE status = 'draft' OR status = 'published'
// Complex nested conditions
const complex = await db
.selectFrom('posts')
.selectAll()
.where((eb) =>
eb.and([
eb('status', '=', 'published'),
eb.or([
eb('author_id', '=', 1),
eb('author_id', '=', 2),
]),
])
)
.execute()
// SQL: WHERE status = 'published' AND (author_id = 1 OR author_id = 2)
3.4 ORDER BY & LIMIT
// Ordered results with pagination
const page = await db
.selectFrom('posts')
.select(['id', 'title', 'created_at'])
.where('status', '=', 'published')
.orderBy('created_at', 'desc')
.limit(10)
.offset(0)
.execute()
// Multiple sort columns
const sorted = await db
.selectFrom('posts')
.selectAll()
.orderBy('status', 'asc')
.orderBy('created_at', 'desc')
.execute()
3.5 Aggregations
// COUNT
const { count } = await db
.selectFrom('posts')
.select((eb) => eb.fn.count<number>('id').as('total'))
.where('status', '=', 'published')
.executeTakeFirstOrThrow()
// GROUP BY
const stats = await db
.selectFrom('posts')
.select((eb) => [
'author_id',
eb.fn.count<number>('id').as('post_count'),
eb.fn.avg<number>('view_count').as('avg_views'),
eb.fn.max<number>('view_count').as('max_views'),
])
.groupBy('author_id')
.having((eb) => eb.fn.count('id'), '>', 5)
.execute()
// Returns: { author_id: number; post_count: number; avg_views: number; max_views: number }[]
Part 4: Type-Safe JOINs
This is where Kysely truly shines. JOINs are fully type-safe — the result type automatically includes columns from all joined tables.
4.1 Inner Join
// Basic INNER JOIN
const postsWithAuthors = await db
.selectFrom('posts')
.innerJoin('users', 'users.id', 'posts.author_id')
.select([
'posts.id',
'posts.title',
'users.username as author_name',
])
.execute()
// Returns: { id: number; title: string; author_name: string }[]
// Multiple JOINs
const fullPosts = await db
.selectFrom('posts')
.innerJoin('users', 'users.id', 'posts.author_id')
.innerJoin('post_tags', 'post_tags.post_id', 'posts.id')
.innerJoin('tags', 'tags.id', 'post_tags.tag_id')
.select([
'posts.title',
'users.username',
'tags.name as tag_name',
])
.execute()
4.2 Left Join (Nullable Results)
// LEFT JOIN — posts may or may not have comments
const postsWithCommentCount = await db
.selectFrom('posts')
.leftJoin('comments', 'comments.post_id', 'posts.id')
.select((eb) => [
'posts.id',
'posts.title',
eb.fn.count<number>('comments.id').as('comment_count'),
])
.groupBy(['posts.id', 'posts.title'])
.execute()
4.3 Dynamic JOINs
// Conditionally join based on runtime parameters
function buildQuery(db: Kysely<Database>, includeAuthor: boolean) {
let query = db
.selectFrom('posts')
.select(['posts.id', 'posts.title'])
if (includeAuthor) {
query = query
.innerJoin('users', 'users.id', 'posts.author_id')
.select(['users.username as author_name'])
}
return query.execute()
}
Part 5: INSERT
5.1 Single Insert
// Insert and return the row (PostgreSQL)
const newUser = await db
.insertInto('users')
.values({
username: 'johndoe',
email: 'john@example.com',
password_hash: await hashPassword('securepass'),
role: 'user',
metadata: { login_count: 0, last_ip: null, preferences: { theme: 'dark', notifications: true } },
})
.returningAll()
.executeTakeFirstOrThrow()
// Insert without returning (MySQL / SQLite)
await db
.insertInto('users')
.values({
username: 'janedoe',
email: 'jane@example.com',
password_hash: await hashPassword('anotherpass'),
role: 'user',
metadata: { login_count: 0, last_ip: null, preferences: { theme: 'light', notifications: false } },
})
.execute()
5.2 Bulk Insert
await db
.insertInto('tags')
.values([
{ name: 'TypeScript', slug: 'typescript' },
{ name: 'Node.js', slug: 'nodejs' },
{ name: 'PostgreSQL', slug: 'postgresql' },
{ name: 'Kysely', slug: 'kysely' },
])
.execute()
5.3 Insert with ON CONFLICT (Upsert)
// PostgreSQL: Insert or update on conflict
await db
.insertInto('users')
.values({
username: 'johndoe',
email: 'john@example.com',
password_hash: await hashPassword('newpass'),
role: 'user',
metadata: { login_count: 1, last_ip: '192.168.1.1', preferences: { theme: 'dark', notifications: true } },
})
.onConflict((oc) =>
oc.column('username').doUpdateSet({
email: (eb) => eb.ref('excluded.email'),
password_hash: (eb) => eb.ref('excluded.password_hash'),
})
)
.execute()
// MySQL equivalent: INSERT ... ON DUPLICATE KEY UPDATE
await db
.insertInto('post_tags')
.values({ post_id: 1, tag_id: 2 })
.onDuplicateKeyUpdate({
post_id: 1,
tag_id: 2,
})
.execute()
Part 6: UPDATE
6.1 Basic Update
// Update single row
await db
.updateTable('users')
.set({
bio: 'Full-stack developer & open source enthusiast',
updated_at: new Date(),
})
.where('id', '=', 1)
.execute()
// Update with return (PostgreSQL)
const updated = await db
.updateTable('posts')
.set({ status: 'published', published_at: new Date() })
.where('id', '=', 42)
.returningAll()
.executeTakeFirstOrThrow()
6.2 Conditional Updates
// Update with expression builder
await db
.updateTable('posts')
.set((eb) => ({
view_count: eb('view_count', '+', 1),
updated_at: new Date(),
}))
.where('id', '=', 42)
.execute()
// Bulk update
await db
.updateTable('posts')
.set({ status: 'archived' })
.where('created_at', '<', new Date('2025-01-01'))
.where('status', '=', 'draft')
.execute()
Part 7: DELETE
// Delete single row
await db
.deleteFrom('comments')
.where('id', '=', 100)
.execute()
// Bulk delete
await db
.deleteFrom('posts')
.where('status', '=', 'draft')
.where('created_at', '<', new Date('2025-06-01'))
.execute()
// Delete with return (PostgreSQL)
const deleted = await db
.deleteFrom('posts')
.where('id', '=', 42)
.returningAll()
.executeTakeFirstOrThrow()
Part 8: Subqueries
8.1 Scalar Subqueries
// Subquery in SELECT
const postsWithRanking = await db
.selectFrom('posts')
.select((eb) => [
'id',
'title',
'view_count',
eb
.selectFrom('posts')
.select((eb) => eb.fn.count<number>('id').as('total'))
.whereRef('posts.author_id', '=', 'posts.author_id')
.as('author_post_count'),
])
.execute()
8.2 Subqueries in WHERE
// Posts by authors who have more than 10 posts
const postsByProlificAuthors = await db
.selectFrom('posts')
.selectAll()
.where('author_id', 'in', (qb) =>
qb
.selectFrom('posts')
.select('author_id')
.groupBy('author_id')
.having((eb) => eb.fn.count('id'), '>', 10)
)
.execute()
8.3 EXISTS Subquery
// Users who have at least one published post
const usersWithPosts = await db
.selectFrom('users')
.selectAll()
.where((eb) =>
eb.exists(
eb
.selectFrom('posts')
.select('id')
.whereRef('posts.author_id', '=', 'users.id')
.where('posts.status', '=', 'published')
)
)
.execute()
Part 9: Transactions
9.1 Basic Transaction
// All-or-nothing transaction
const result = await db.transaction().execute(async (trx) => {
// Deduct from sender
await trx
.updateTable('accounts')
.set((eb) => ({ balance: eb('balance', '-', 100) }))
.where('id', '=', senderId)
.execute()
// Add to receiver
await trx
.updateTable('accounts')
.set((eb) => ({ balance: eb('balance', '+', 100) }))
.where('id', '=', receiverId)
.execute()
// Create transaction record
return trx
.insertInto('transactions')
.values({
from_id: senderId,
to_id: receiverId,
amount: 100,
created_at: new Date(),
})
.returningAll()
.executeTakeFirstOrThrow()
})
9.2 Savepoints (Nested Transactions)
await db.transaction().execute(async (trx) => {
// Outer transaction operations
await trx.insertInto('posts').values({ ... }).execute()
// Create a savepoint for partial rollback
await trx
.savepoint('after_post_insert')
.execute(async (sp) => {
try {
await sp.insertInto('tags').values({ ... }).execute()
} catch {
// Rollback to savepoint — post is kept, tag is rolled back
await sp.rollback('after_post_insert').execute()
}
})
// Continue with outer transaction
await trx.updateTable('users').set({ ... }).execute()
})
Part 10: Advanced Patterns
10.1 Common Table Expressions (CTEs)
// Recursive CTE for hierarchical data
const threadedComments = await db
.with('comment_tree', (db) =>
db
.selectFrom('comments')
.select(['id', 'content', 'parent_id', 'author_id'])
.where('parent_id', 'is', null)
.unionAll(
db
.selectFrom('comments')
.innerJoin('comment_tree', 'comment_tree.id', 'comments.parent_id')
.select([
'comments.id',
'comments.content',
'comments.parent_id',
'comments.author_id',
])
)
)
.selectFrom('comment_tree')
.selectAll()
.execute()
10.2 Window Functions
// Rank posts within each author by view count
const rankedPosts = await db
.selectFrom('posts')
.select((eb) => [
'id',
'title',
'author_id',
'view_count',
eb.fn
.agg<number>('rank')
.over((ob) =>
ob.partitionBy('author_id').orderBy('view_count', 'desc')
)
.as('rank'),
])
.execute()
10.3 Pivoting Data
// Pivot post statuses into columns
const pivotResult = await db
.selectFrom('posts')
.select((eb) => [
'author_id',
eb.fn.count<number>('id').filterWhere('status', '=', 'draft').as('draft_count'),
eb.fn.count<number>('id').filterWhere('status', '=', 'published').as('published_count'),
eb.fn.count<number>('id').filterWhere('status', '=', 'archived').as('archived_count'),
])
.groupBy('author_id')
.execute()
10.4 Raw SQL Interop
When you need database-specific features Kysely doesn’t support:
// Raw expression in otherwise type-safe query
import { sql } from 'kysely'
const result = await db
.selectFrom('posts')
.select((eb) => [
'id',
'title',
// Use raw SQL for full-text search (PostgreSQL)
sql<string>`plainto_tsquery('english', ${searchTerm})`.as('query'),
sql<number>`ts_rank(to_tsvector('english', title || ' ' || content), plainto_tsquery('english', ${searchTerm}))`.as('relevance'),
])
.where(
sql`to_tsvector('english', title || ' ' || content) @@ plainto_tsquery('english', ${searchTerm})`,
'=',
true
)
.orderBy('relevance', 'desc')
.limit(20)
.execute()
Part 11: Type Generation from Existing Database
If you already have a database, don’t define types manually — generate them:
# PostgreSQL
npx kysely-codegen --dialect postgres --url postgresql://user:pass@localhost:5432/myapp
# MySQL
npx kysely-codegen --dialect mysql --url mysql://user:pass@localhost:3306/myapp
# SQLite
npx kysely-codegen --dialect sqlite --url ./database.db
This generates a db.d.ts file with all table and column types from your actual database schema.
Part 12: Kysely vs ORMs
When to Use Kysely
- You want full SQL power with type safety
- You have complex queries (multiple JOINs, CTEs, window functions)
- You need maximum performance (no abstraction overhead)
- You prefer writing SQL over learning ORM-specific APIs
- You’re migrating from raw SQL and want type safety added incrementally
When to Use an ORM
- You’re building a simple CRUD application
- You want automatic migrations and schema management
- You prefer entity-based thinking over table-based thinking
- Your team is less comfortable with SQL
The Hybrid Approach
Many production applications use both:
// Use Prisma/TypeORM for simple CRUD and migrations
const user = await prisma.user.findUnique({ where: { id: 1 } })
// Use Kysely for complex analytical queries
const stats = await db
.selectFrom('posts')
.select((eb) => [
'author_id',
eb.fn.count<number>('id').as('post_count'),
eb.fn.avg<number>('view_count').as('avg_views'),
])
.groupBy('author_id')
.having((eb) => eb.fn.count('id'), '>', 5)
.execute()
Part 13: Production Tips
13.1 Connection Pooling
Always use connection pools in production:
import { Pool } from 'pg'
const pool = new Pool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20, // Maximum pool size
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
})
const db = new Kysely<Database>({
dialect: new PostgresDialect({ pool }),
})
13.2 Query Logging
const db = new Kysely<Database>({
dialect: new PostgresDialect({ pool }),
log: (event) => {
if (event.level === 'query') {
console.log(`[${event.queryDurationMillis}ms]`, event.query.sql)
console.log('Parameters:', event.query.parameters)
}
},
})
13.3 Error Handling
import { TransactionRollbackError } from 'kysely'
try {
await db.transaction().execute(async (trx) => {
// ... operations
})
} catch (error) {
if (error instanceof TransactionRollbackError) {
console.error('Transaction was rolled back')
} else {
console.error('Database error:', error)
}
}
13.4 Graceful Shutdown
async function shutdown() {
await db.destroy() // Closes all pool connections
process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
Part 14: Real-World Example — Blog API
Putting it all together — a complete blog service using Kysely:
import { Kysely, PostgresDialect } from 'kysely'
import { Pool } from 'pg'
// Types defined above (Database, UsersTable, PostsTable, etc.)
const db = new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({ connectionString: process.env.DATABASE_URL }),
}),
})
// --- Service Layer ---
class PostService {
async listPublished(page: number, limit: number) {
const offset = (page - 1) * limit
const [posts, { count: total }] = await Promise.all([
db
.selectFrom('posts')
.innerJoin('users', 'users.id', 'posts.author_id')
.select([
'posts.id',
'posts.title',
'posts.slug',
'posts.excerpt',
'posts.published_at',
'posts.view_count',
'users.username as author_name',
])
.where('posts.status', '=', 'published')
.orderBy('posts.published_at', 'desc')
.limit(limit)
.offset(offset)
.execute(),
db
.selectFrom('posts')
.select((eb) => eb.fn.count<number>('id').as('count'))
.where('status', '=', 'published')
.executeTakeFirstOrThrow(),
])
return { posts, total, page, limit, totalPages: Math.ceil(total / limit) }
}
async getBySlug(slug: string) {
return db
.selectFrom('posts')
.innerJoin('users', 'users.id', 'posts.author_id')
.select([
'posts.id',
'posts.title',
'posts.slug',
'posts.content',
'posts.excerpt',
'posts.published_at',
'posts.view_count',
'users.username as author_name',
'users.avatar_url as author_avatar',
])
.where('posts.slug', '=', slug)
.where('posts.status', '=', 'published')
.executeTakeFirst()
}
async create(data: CreatePostInput) {
return db.transaction().execute(async (trx) => {
const post = await trx
.insertInto('posts')
.values({
title: data.title,
slug: data.slug,
content: data.content,
excerpt: data.excerpt,
author_id: data.authorId,
status: 'draft',
})
.returningAll()
.executeTakeFirstOrThrow()
if (data.tags?.length) {
await trx
.insertInto('post_tags')
.values(data.tags.map((tagId) => ({ post_id: post.id, tag_id: tagId })))
.execute()
}
return post
})
}
async incrementViews(postId: number) {
await db
.updateTable('posts')
.set((eb) => ({ view_count: eb('view_count', '+', 1) }))
.where('id', '=', postId)
.execute()
}
async getStats() {
return db
.selectFrom('posts')
.select((eb) => [
'author_id',
eb.fn.count<number>('id').as('total_posts'),
eb.fn.count<number>('id').filterWhere('status', '=', 'published').as('published'),
eb.fn.count<number>('id').filterWhere('status', '=', 'draft').as('drafts'),
eb.fn.sum<number>('view_count').as('total_views'),
])
.groupBy('author_id')
.execute()
}
}
export const postService = new PostService()
Conclusion
Kysely fills a crucial gap in the TypeScript ecosystem. It gives you:
- Full SQL power — write any query you can write in raw SQL
- Complete type safety — compile-time errors for column names, types, and JOINs
- Minimal overhead — no entity management, no change tracking, no proxy magic
- Excellent DX — IDE autocomplete, refactoring support, and clear error messages
If you’re building a TypeScript backend and you know SQL, Kysely is the tool that respects your knowledge while protecting you from typos. It’s not about choosing between SQL and type safety — it’s about having both.
The best part? You can adopt it incrementally. Start with one complex query, see the type safety in action, and expand from there.
Give it a try. Your future self will thank you at 3 AM. 🚀
Further Reading
- Kysely Documentation
- Kysely GitHub
- kysely-codegen — Auto-generate types from existing DB
- PostgreSQL Docs — For understanding advanced SQL