<!-- Copyright (c) 2026 Todd Levy. Licensed under MIT. SPDX-License-Identifier: MIT -->
Kysely: Type-Safe SQL Patterns
Kysely (pronounced "Key-Seh-Lee") is a type-safe TypeScript SQL query builder. It generates plain SQL with zero runtime ORM overhead. Every query is validated at compile time with full autocompletion.
Kysely is not an ORM -- no relations, no lazy loading, no magic. Just SQL with types.
When to Use
- "write a Kysely query"
- "create database migration"
- "add a new table"
- "query with joins / subqueries / CTEs"
- "JSONB or array column operations"
- Working with an existing Kysely + PostgreSQL codebase
- Debugging Kysely type inference issues
Outcomes
- Artifact: Type-safe queries using ExpressionBuilder patterns
- Artifact: Migration files via kysely-ctl
- Decision: When to use query builder vs template tag
Core Philosophy
Prefer Kysely's query builder for everything it can express. Fall back to
template tag only when the builder lacks support.
| Use Case | Approach |
|---|
| Schema definitions | Kysely migrations () |
| Simple CRUD | Query builder (, , , ) |
| JOINs (any complexity) | Query builder (callback format for complex joins) |
| Aggregations / GROUP BY | Query builder with |
| CTEs | Query builder () |
| Relations / nested JSON | / helpers |
| Conditional queries | or dynamic filter arrays |
| Reusable fragments | helper functions |
| Dynamic columns/tables | / with allowlisted values |
| Dynamic SQL fragments | with allowlisted values, for arrays |
| Dialect-specific syntax | template tag |
| Unsupported operators | template tag |
Need a query?
Can Kysely's builder express it?
YES -> Use the query builder (type-safe, composable)
NO -> Use sql`` template tag (always type your output: sql<Type>`...`)
ExpressionBuilder (eb) Cheat Sheet
The
callback parameter is the foundation of type-safe query building:
| Method | Purpose | Example |
|---|
| Column reference | |
| Parameterized value ($1) | |
| SQL literal (numbers, bools, null only) | , |
| Typed function call | eb.fn<string>("upper", [eb.ref("email")])
|
| COUNT aggregate | eb.fn.count("id").as("count")
|
eb.fn.sum / avg / min / max
| Other aggregates | eb.fn.sum("amount").as("total")
|
eb.fn.coalesce(col, fallback)
| COALESCE | eb.fn.coalesce("col", eb.val(0))
|
eb.case().when().then().else().end()
| CASE expression | see query-patterns.md |
| / | Combine conditions | eb.or([eb("a","=",1), eb("b","=",2)])
|
| EXISTS check | eb.exists(db.selectFrom(...))
|
| Negate expression | |
| SQL CAST | eb.cast(eb.val("x"), "text")
|
| Binary expression | eb("qty", "*", eb.ref("price"))
|
For full query examples, see references/query-patterns.md.
Database Types
typescript
import { Generated, Insertable, Selectable, Updateable } from "kysely"
interface Database {
users: UsersTable
posts: PostsTable
}
interface UsersTable {
id: Generated<number>
email: string
name: string
created_at: Generated<Date>
}
// Helper types make Generated fields optional for inserts/updates
type NewUser = Insertable<UsersTable>
type UserUpdate = Updateable<UsersTable>
type User = Selectable<UsersTable>
Use
to generate these types from your database. See
references/migrations.md.
Pitfalls
These are the most common mistakes when writing Kysely code.
1. eb.val() vs eb.lit() confusion
creates parameterized values ($1) -- use for user input.
creates SQL literals --
only accepts numbers, booleans, null (not strings). For string literals, use
'value'``.
typescript
eb.val("safe input") // $1 -- parameterized, safe
eb.lit(42) // 42 -- literal in SQL
eb.lit("text") // THROWS "unsafe immediate value"
eb.cast(eb.val("text"), "text") // $1::text -- workaround for typed string params
2. Forgetting .execute()
Queries are lazy builders. Without an execute method, nothing runs.
typescript
db.selectFrom("user").selectAll() // does nothing
await db.selectFrom("user").selectAll().execute() // runs the query
3. .where() vs .whereRef() for column comparisons
compares column
to the
string . Use
for column-to-column comparisons.
typescript
.where("table.col", "=", "other.col") // compares to string literal
.whereRef("table.col", "=", "other.col") // compares two columns
4. Always type sql`` template literals
template literals infer as
. Always provide an explicit type parameter.
typescript
sql`now()` // Expression<unknown> -- bad
sql<Date>`now()` // Expression<Date> -- good
5. selectAll() breaks nested json helper type inference (#1059)
Bare
inside json helper subqueries merges outer table columns into the type. Use table-qualified
instead. See
references/relations-helpers.md.
6. DATE columns cause timezone drift
The
driver converts DATE to JS
, causing timezone issues. Parse DATE as string instead. See
references/migrations.md.
7. "Type instantiation is excessively deep"
Complex queries with many CTEs can exceed TypeScript's type depth. Use
on intermediate CTEs. See
references/relations-helpers.md.
8. PostgreSQL does NOT auto-index foreign keys
Always create indexes on FK columns manually in migrations. See references/migrations.md.
9. CamelCasePlugin causes drift with raw SQL
converts snake_case DB columns to camelCase in the builder. But raw
template queries bypass the plugin, creating inconsistent naming between builder and raw queries in the same codebase. If you use significant raw SQL alongside the builder, avoid this plugin and keep snake_case throughout. See
references/migrations.md.
10. JSONB inserts need JSON.stringify only in sql templates (#209)
The
driver auto-serializes objects for
/
JSONB params (
pg types). You only need explicit
inside
template expressions or with non-pg drivers. See
references/jsonb-arrays.md.
11. Pool queries use different connections (API, #330)
Each query may use a different pooled connection.
, session variables, and RLS context do not persist across queries. Use
or
to pin multiple statements to one connection. See
references/advanced-patterns.md.
12. WHERE does not narrow result types (#310)
.where('col', 'is not', null)
does
not remove
from the result type. Use
to manually assert the narrowed shape. See
references/advanced-patterns.md.
13. Team migration ordering (#697)
Migrations added on parallel branches may fail strict ordering when merged. Set
allowUnorderedMigrations: true
on the
. See
references/migrations.md.
14. JSON aggregation changes runtime types (#1412)
columns inside
/
/
results become
strings at runtime because JSON has no Date type. TypeScript types still say
. Parse dates manually at the boundary. See
references/jsonb-arrays.md.
Official Resources
| Resource | URL |
|---|
| LLM-friendly docs (full) | https://kysely.dev/llms-full.txt
|
| API documentation | https://kysely-org.github.io/kysely-apidoc
|
| Playground | |
| GitHub | https://github.com/kysely-org/kysely
|
| Awesome Kysely (ecosystem) | https://github.com/kysely-org/awesome-kysely
|
When using Cursor
, reference
https://kysely.dev/llms-full.txt
for the most complete context.
Reference Files
Consult these for detailed code patterns:
| Reference | When to Use |
|---|
| query-patterns.md | SELECT, WHERE, JOINs, aggregations, ORDER BY, mutations, $if, subqueries, transactions |
| jsonb-arrays.md | JSONB columns, array columns, JSONPath, querying JSON/array data |
| relations-helpers.md | jsonArrayFrom, jsonObjectFrom, reusable Expression<T> helpers, CTEs, compile/InferResult |
| migrations.md | kysely-ctl setup, migration files, column types, type generation, plugins, Neon dialect, DATE fix |
| advanced-patterns.md | Dynamic columns, withSchema, connection pinning, RLS, $narrowType, streaming, MERGE, views, FTS, testing |
| ecosystem.md | Pagination, auth adapters, Fastify plugin, community dialects |