TL;DR:

  • Kysely is a TypeScript SQL query builder that generates fully typed query results from your database schema definition — column typos, wrong join references, and type mismatches are caught at compile time
  • It produces raw SQL rather than an ORM abstraction layer, giving you full control over query structure while the TypeScript compiler validates your queries against the schema
  • Kysely supports PostgreSQL, MySQL, SQLite, and MS SQL Server, and works with edge runtimes (Cloudflare Workers, Bun) via dialect plugins

The promise of TypeScript in backend code collides with SQL at the boundary where you write query strings or use an ORM’s magical find methods. Either you lose type safety at the query layer entirely, or you accept the trade-offs of an ORM that hides what SQL it’s generating. Kysely occupies the space between: a query builder that stays close to SQL while giving the TypeScript compiler enough information to validate your queries against your schema.

What Kysely Does

Kysely is a query builder — not an ORM. It does not define models, manage migrations, track entity relationships, or map object graphs to rows. What it does is provide a chainable TypeScript API for constructing SQL queries where:

  • The columns you can select, filter by, or join on are constrained to those that exist in the table you’re querying
  • The type of each column in the result is inferred from your schema definition
  • References to columns after joins are validated against the joined table’s schema
  • Inserting or updating records fails the type check if you provide the wrong shape

The SQL that Kysely generates is predictable and readable — you control exactly what query is built.

Defining the Database Schema

Kysely’s type safety starts with a database interface that maps table names to row types:

import { Generated, ColumnType } from "kysely";

// Generated<T> marks auto-generated columns (serial IDs, default timestamps)
interface UserTable {
  id: Generated<number>;
  email: string;
  name: string;
  created_at: ColumnType<Date, never, never>; // read-only: generated by DB
  role: "admin" | "user" | "moderator";
}

interface PostTable {
  id: Generated<number>;
  user_id: number;
  title: string;
  body: string;
  published: boolean;
  published_at: Date | null;
}

interface Database {
  users: UserTable;
  posts: PostTable;
}

This interface is the single source of truth for Kysely’s type inference. You can generate it from an existing database using tools like kysely-codegen, or define it manually alongside a migration tool like db-migrate or node-pg-migrate.

Initializing Kysely

import { Kysely, PostgresDialect } from "kysely";
import { Pool } from "pg";

const db = new Kysely<Database>({
  dialect: new PostgresDialect({
    pool: new Pool({
      connectionString: process.env.DATABASE_URL,
    }),
  }),
});

export { db };

The generic Kysely<Database> parameter binds the query builder to your schema. All queries built from this instance are validated against the Database interface.

Querying

// Select with type-safe columns
const users = await db
  .selectFrom("users")
  .select(["id", "email", "name", "role"])
  .where("role", "=", "admin")
  .orderBy("created_at", "desc")
  .execute();

// users: Array<{ id: number; email: string; name: string; role: "admin" | "user" | "moderator" }>
// TypeScript infers the result type from the selected columns

// Compile error: "password" does not exist on UserTable
const bad = await db
  .selectFrom("users")
  .select(["id", "password"]) // TS error here
  .execute();

Joins

// Join with full type inference on result
const postsWithAuthors = await db
  .selectFrom("posts")
  .innerJoin("users", "users.id", "posts.user_id")
  .select([
    "posts.id",
    "posts.title",
    "posts.published_at",
    "users.email as author_email",
    "users.name as author_name",
  ])
  .where("posts.published", "=", true)
  .execute();

// Type: Array<{
//   id: number;
//   title: string;
//   published_at: Date | null;
//   author_email: string;
//   author_name: string;
// }>

After the join, select() autocompletes columns from both posts and users. A typo like "users.emai" is a TypeScript error immediately.

Inserts and Updates

Generated<T> marks columns that the database generates (serial IDs, default timestamps). Kysely’s insert types automatically exclude these, so you can’t accidentally include id in an insert:

// Insert — id and created_at excluded from required type (they're Generated/ColumnType)
const newUser = await db
  .insertInto("users")
  .values({
    email: "alice@example.com",
    name: "Alice",
    role: "user",
  })
  .returning(["id", "email"])
  .executeTakeFirstOrThrow();

// Update — where clause is type-checked against column types
await db
  .updateTable("users")
  .set({ role: "admin" })
  .where("id", "=", 42)
  .execute();

// Compile error: "superuser" is not assignable to "admin" | "user" | "moderator"
await db
  .updateTable("users")
  .set({ role: "superuser" }) // TS error
  .where("id", "=", 42)
  .execute();

Transactions

await db.transaction().execute(async (trx) => {
  const post = await trx
    .insertInto("posts")
    .values({
      user_id: userId,
      title: "Draft post",
      body: "",
      published: false,
      published_at: null,
    })
    .returning("id")
    .executeTakeFirstOrThrow();

  await trx
    .updateTable("users")
    .set({ last_active: new Date() })
    .where("id", "=", userId)
    .execute();

  return post;
});

The trx object passed to the callback is a fully typed Kysely<Database> instance scoped to the transaction. All queries inside the callback use the transaction connection.

Dynamic Queries

Real applications build queries dynamically based on runtime conditions. Kysely’s expressionBuilder provides a type-safe escape hatch:

interface PostFilters {
  published?: boolean;
  authorId?: number;
  after?: Date;
}

function buildPostQuery(filters: PostFilters) {
  let query = db
    .selectFrom("posts")
    .innerJoin("users", "users.id", "posts.user_id")
    .select(["posts.id", "posts.title", "posts.published_at", "users.name"]);

  if (filters.published !== undefined) {
    query = query.where("posts.published", "=", filters.published);
  }

  if (filters.authorId !== undefined) {
    query = query.where("posts.user_id", "=", filters.authorId);
  }

  if (filters.after !== undefined) {
    query = query.where("posts.published_at", ">", filters.after);
  }

  return query.execute();
}

TypeScript tracks the query type through the chain even as conditions are applied conditionally.

Edge and Serverless Runtimes

Kysely’s dialect system allows it to run on any JavaScript runtime. For Cloudflare Workers with D1 (SQLite):

import { Kysely } from "kysely";
import { D1Dialect } from "kysely-d1";

const db = new Kysely<Database>({
  dialect: new D1Dialect({ database: env.DB }),
});

Community dialects exist for PlanetScale (MySQL over HTTP), Neon (PostgreSQL over HTTP/WebSockets), libSQL (Turso), and other serverless-compatible databases. The query-building API is identical regardless of the dialect.

Schema Generation with kysely-codegen

Rather than writing the Database interface by hand, kysely-codegen introspects a live database and generates it:

npx kysely-codegen --url postgresql://localhost/mydb --out-file src/db/schema.ts

The generated file contains the Database interface with types inferred from the actual PostgreSQL column types, including nullable columns (Type | null), enums, and generated columns.

When Kysely Makes Sense

Kysely earns its place when:

  • You want type safety at the SQL layer without ORM overhead: You write recognizable SQL, and the TypeScript compiler validates it against your schema.
  • You’re already comfortable writing SQL: Kysely’s API maps closely to SQL structure. If you find ORM query APIs more confusing than the SQL they generate, Kysely’s explicit model is an improvement.
  • Edge or serverless deployments: The dialect system and lack of native bindings make it well-suited for environments where a traditional database driver won’t run.

It is less compelling if you want automatic relation loading, migration management, or a high-level find/save API. Prisma, Drizzle ORM, or TypeORM provide more scaffolding for those patterns. Kysely is specifically a query builder — it gives you the SQL construction layer with type safety, and nothing more.

References