Kysely(キーセリー)は、TypeScriptでSQLを組み立てるときに、テーブル名や列名の誤りをコンパイルの段階で止めてくれるクエリビルダーです。書き方はSQLにほぼそのまま対応し、ORMのようにモデルやリレーションを定義する層を持ちません。2026年10月29日にnpm registryで確認した最新版は0.29.7で、0.29系からはNode.js 22以上・ESMのみの配布になりました。この記事では、PostgreSQLにつないで型付きのクエリを投げるまでのコード、kysely/migrationへ移ったマイグレーションの運用、2026年に公表された4件の脆弱性、PrismaやDrizzleを選ぶべき場面との境界までを扱います。
まとめ:Kyselyを採用する条件とPrisma・DrizzleなどのORMを選ぶ境界
Kyselyが向くのは、SQLを読み書きできる開発者が、既存のデータベースに型を付けて安全に触りたい案件です。発行されるSQLはコードの見た目とほぼ一致するため、実行計画の確認やDBAとのレビューがそのまま通ります。ライセンスはMITで、ランタイムの依存パッケージはありません。
反対に、リレーションをたどるCRUDが中心で、SQLに慣れていない開発者が多い体制なら、モデル定義と関連の読み込みを持つPrismaやDrizzleのほうが早く仕上がります。Kyselyは型の元になるスキーマ定義もマイグレーションの自動生成も持たないため、その部分を運用で補う必要があるからです。また、0.28.17より前の版にはSQLインジェクションの脆弱性が報告されており、既存案件では版の確認を最初に行ってください。
Kyselyの定義とORMではないクエリビルダーとしての位置づけ・0.29系の版
最初に、Kyselyが何を受け持ち、何を受け持たないのかを整理します。ここを取り違えると、ORMの感覚で使い始めて関連データの取得で手が止まります。
型安全なSQLクエリビルダーという設計とKnexから受け継いだ書き方
公式のIntroductionは、Kyselyを「型安全で補完の効くTypeScriptのSQLクエリビルダー」と説明し、Knexに着想を得たと明記しています。selectFrom、where、innerJoinといったメソッドをSQLの句の順に連ねる書き方はKnexと同じです。違いは型にあります。
Kyselyは、データベースの全テーブルと列をTypeScriptのインターフェースとして受け取ります。そのため、存在しない列をwhereに書けばエディタ上で赤線が出て、selectで選んだ列だけが結果の型に残るのです。結合や副問い合わせ、列の別名まで型に反映されます。型で表せない書き方に出会ったときは、sqlタグで生のSQLを書けます。
0.29.7までの版の変遷と2026年10月時点のnpm実測値
Kyselyはまだ1.0に達しておらず、0.x系の中で破壊的変更が入ります。npm registryで2026年10月29日に確認した主な版は次のとおりです。
| 版 | 公開日 | 扱い |
|---|---|---|
| 0.28.0 | 2025年4月13日 | 0.28系の初版 |
| 0.28.17 | 2026年5月3日 | 既知の脆弱性4件の修正を含む |
| 0.29.0 | 2026年5月8日 | ESMのみ・移行機能の分離 |
| 0.29.7 | 2026年10月17日 | 最新版(latest) |
| 0.28.18 | 2026年10月17日 | 0.28系の保守版 |
| 0.30.0-beta.2 | 2026年9月14日 | 次期版のベータ |
0.29.7のpackage.jsonは"engines": { "node": ">=22.0.0" }で、配布形式はESMのみです。0.28系はrelease-0.28というタグで保守が続いており、すぐにNode.js 22へ上げられない案件はこちらに留まれます。GitHubリポジトリのスター数は同日時点で14,312件、npmの週間ダウンロード数は10月21日〜27日の7日間で約2,282万件でした。
ORMとの違いはリレーションとスキーマ定義を持たない点にある
公式のRelationsレシピは冒頭で「KyselyはORMではない」「リレーションの概念を持たない」と言い切っています。user.postsのように関連をたどる書き方は無く、関連データを取得するには、結合か副問い合わせを自分で記述する必要がある設計です。ORMの一般的な仕組みはORMとはで解説しています。
もう1つの違いは、型の出どころです。Prismaはschema.prisma、DrizzleはpgTable()でスキーマを定義し、そこから型とマイグレーションを作ります。Kyselyは逆で、データベースの実物が正です。型は手で書くか、後述のkysely-codegenでデータベースから読み取って作ります。
npmでKyselyを入れてPostgreSQLに型付きクエリを投げる手順
受注と顧客の2テーブルを例に、接続から読み書き、関連行の取得、トランザクションまでのコードを示します。Node.js 22以上とTypeScriptのプロジェクトを想定しています。
インストールとDatabase型の定義からKyselyインスタンス生成まで
公式のGetting startedに沿って、本体とPostgreSQLのドライバーを入れます。TypeScriptは5.4以上が必須で、5.9以上が推奨されています。tsconfig.jsonの"strict": trueも必須条件です。
npm install kysely pg
npm install -D @types/pg typescript tsx
次のファイルで、テーブルの型と接続を定義します。組み込みのダイアレクトはPostgreSQL・MySQL・SQL Server・SQLite・PGliteの5種類で、ドライバーだけを差し替えます。
// db.ts:テーブルの型と接続をまとめる
import { Pool } from 'pg'
import { Kysely, PostgresDialect } from 'kysely'
import type { ColumnType, Generated, Insertable, Selectable } from 'kysely'
interface CustomerTable {
id: Generated<number> // DBが採番する列。INSERT時は省略できる
name: string
}
interface OrdersTable {
id: Generated<number>
customer_id: number
status: 'received' | 'shipped'
amount: number
// SELECTではDate、INSERTでは文字列か省略、UPDATEでは変更不可
created_at: ColumnType<Date, string | undefined, never>
}
export interface Database {
customer: CustomerTable
orders: OrdersTable
}
export type Order = Selectable<OrdersTable>
export type NewOrder = Insertable<OrdersTable>
export const db = new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({ connectionString: process.env.DATABASE_URL, max: 10 }),
}),
})
GeneratedとColumnTypeは、SELECT・INSERT・UPDATEで型を変えたい列に使います。公式は、テーブルのインターフェースをそのまま結果の型に使わず、SelectableやInsertableで包んだ型を使うよう勧めています。インスタンスは1つのデータベースにつき1つだけ作り、アプリ全体で共有してください。
selectFromとinsertIntoで受注データを読み書きする基本のクエリ
先ほどのdbを使って、受付中の受注を顧客名つきで取得し、1件を登録します。
// orders.ts:受注の検索と登録
import { db } from './db.js'
// 結果の型は { id: number; name: string; amount: number }[] と推論される
const rows = await db
.selectFrom('orders')
.innerJoin('customer', 'customer.id', 'orders.customer_id')
.select(['orders.id', 'customer.name', 'orders.amount'])
.where('orders.status', '=', 'received')
.orderBy('orders.id', 'desc')
.limit(50)
.execute()
// 1行を登録して採番されたidを受け取る(0行ならエラーにする)
const { id } = await db
.insertInto('orders')
.values({ customer_id: 1, status: 'received', amount: 12000 })
.returning('id')
.executeTakeFirstOrThrow()
// 実行せずに発行予定のSQLを確かめる
const compiled = db.selectFrom('orders').select('id').where('amount', '>', 10000).compile()
console.log(compiled.sql) // select "id" from "orders" where "amount" > $1
console.log(compiled.parameters) // [ 10000 ]
'orders.status'を'orders.state'と打ち間違えれば、実行前に型エラーになります。値はすべて$1のようなプレースホルダーで送られるため、通常の書き方ではSQLインジェクションは起きません。並べ替えはorderBy('orders.id', 'desc')と書きます。古い記事にあるorderBy('id desc')や配列での指定は、0.29.7の型定義で非推奨になっています。
jsonArrayFromで関連行を1回のSQLにまとめてN+1を避ける書き方
リレーションを持たないKyselyで「顧客ごとの受注一覧」を取るには、副問い合わせの結果をJSONに集約します。公式がkysely/helpers/postgresに用意しているjsonArrayFromを使えば、ループの中で受注を1件ずつ引くN+1の形になりません。
import { jsonArrayFrom } from 'kysely/helpers/postgres'
import { db } from './db.js'
// 顧客1人につき受注の配列を1列にまとめて返す(SQLは1回)
const customers = await db
.selectFrom('customer')
.select((eb) => [
'customer.id',
'customer.name',
jsonArrayFrom(
eb.selectFrom('orders')
.select(['orders.id', 'orders.amount', 'orders.status'])
.whereRef('orders.customer_id', '=', 'customer.id')
.orderBy('orders.id', 'desc')
).as('orders'),
])
.execute()
MySQL用とSQLite用のヘルパーも同じ名前で用意されています。ただしMySQLは8.0.14以上が必要で、MariaDBは対象外です。組み込みのSQLiteダイアレクトではJSON列が文字列のまま返るため、ParseJSONResultsPluginを足してください。ORMごとのN+1の起き方と検出方法はN+1問題とはで比べています。
トランザクションとsqlテンプレートタグで型の外へ出る具体的な場面
受注と明細のように複数のテーブルをまとめて更新するときは、公式のトランザクションの例のとおりdb.transaction().execute()を使います。コールバック内の処理が例外を投げれば、全体がロールバックされます。
import { sql } from 'kysely'
import { db } from './db.js'
// 受注登録と顧客名の更新を1つのトランザクションで行う
await db.transaction().execute(async (trx) => {
await trx.insertInto('orders')
.values({ customer_id: 1, status: 'received', amount: 8000 })
.execute()
await trx.updateTable('customer')
.set({ name: '株式会社サンプル' })
.where('id', '=', 1)
.execute()
})
// 日別集計のように型で書きにくい処理は sql タグで書く(${} の値はパラメーターになる)
const from = '2026-10-01'
const { rows } = await sql<{ day: string; total: number }>`
select to_char(created_at, 'YYYY-MM-DD') as day, sum(amount)::int as total
from orders where created_at >= ${from}
group by 1 order by 1`.execute(db)
sqlタグの${}に入れた値はプレースホルダーとして送られます。一方、公式のRaw SQLレシピにあるsql.lit()やsql.raw()は値をSQL文字列へ直接埋め込みます。利用者の入力を渡してはいけません。また、sql<T>の型引数は検証されない宣言にすぎず、実際の列と食い違っても気づけない点にも注意が要ります。
Kyselyのマイグレーション運用とkysely-codegenで型を同期する手順
Kyselyにはスキーマの差分からマイグレーションを作る機能がありません。手書きのマイグレーションでデータベースを変え、そのデータベースから型を作り直すという順番で回します。
kysely/migrationのMigratorとup・downの記述例
公式のMigrationsガイドに沿って、upとdownを書いたファイルを作ります。引数の型はKysely<any>にします。マイグレーションはその時点のスキーマに固定されるべきもので、今のアプリの型に依存させないためです。
// migrations/2026-10-29_01_create_orders.ts
import { sql } from 'kysely'
import type { Kysely } from 'kysely'
export async function up(db: Kysely<any>): Promise<void> {
await db.schema.createTable('customer')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('name', 'text', (col) => col.notNull())
.execute()
await db.schema.createTable('orders')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('customer_id', 'integer', (col) => col.references('customer.id').notNull())
.addColumn('status', 'text', (col) => col.notNull())
.addColumn('amount', 'integer', (col) => col.notNull())
.addColumn('created_at', 'timestamptz', (col) => col.defaultTo(sql`now()`).notNull())
.execute()
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('orders').execute()
await db.schema.dropTable('customer').execute()
}
// migrate.ts:未適用のマイグレーションをすべて流す(npx tsx migrate.ts)
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { FileMigrationProvider, Migrator } from 'kysely/migration' // 0.29からはこちら
import { db } from './db.js'
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(import.meta.dirname, 'migrations'), // 絶対パスで渡す
}),
})
const { error, results } = await migrator.migrateToLatest()
for (const r of results ?? []) console.log(r.status, r.migrationName)
await db.destroy()
if (error) { console.error(error); process.exit(1) }
0.29.0のリリースノートのとおり、MigratorとFileMigrationProviderはkysely本体からkysely/migrationへ移りました。0.28系の記事のままfrom 'kysely'で読み込むと、コンパイルの段階で止まります。ファイルは名前の英数字順に流れ、既定では適用済みより前の名前を後から足すとエラーです。日付を先頭に付ける命名が勧められているのはこのためです。適用履歴はkysely_migration表、排他の管理はkysely_migration_lock表に残ります。
kysely-codegenでDBから型を生成し手書きの型とのずれを防ぐ
手書きのDatabase型は、マイグレーションを流すたびに直す必要があり、いずれ実物とずれます。公式のGenerating typesは型生成の道具を5つ挙げており、データベースに接続して読み取るkysely-codegenが代表です。2026年10月29日時点の最新版は0.20.0でした。
npm install -D kysely-codegen
# 接続先は環境変数で渡す(.env はコミットしない)
DATABASE_URL=postgres://app:secret@localhost:5432/sales npx kysely-codegen --out-file ./src/db/db.d.ts
出力先を指定しないとnode_modulesの中に書き出され、パッケージを入れ直すと消えます。--out-fileでリポジトリ内に置き、マイグレーションと同じコミットに含めてください。CIで「マイグレーションを流す→型を生成する→差分があれば失敗させる」という順に組むと、型の更新漏れを機械的に止められます。既存のPrismaスキーマから型を作るprisma-kyselyもあり、Prismaとの併用や段階的な乗り換えで使える選択肢です。マイグレーションの作成と実行をコマンドで行いたいなら、公式組織のkysely-ctlがあります。ただし公式ガイドはこれをコア機能の外と位置付けています。
PgBouncerのトランザクションモードで移行ロックが効かない問題
本番の接続をPgBouncerのトランザクションモードで束ねている構成では、マイグレーションだけ別経路にする必要があります。0.29.7の配布物を見ると、PostgreSQLの移行ロックはpg_advisory_lockによるセッション単位のロックで、待ち時間の上限は1時間に設定されていました。0.28.18のリリースノートも、この変更を破壊的変更として告知しています。
セッション単位のロックは、取得した接続と解放する接続が同じであることが前提です。トランザクションごとに接続が入れ替わるプーラーを通すと、この前提が崩れます。マイグレーション用の接続文字列はPgBouncerを通さずPostgreSQLへ直接つなぐものにしてください。サーバーレスのPostgresを使う場合も同じ考え方で、プーラー経由と直接接続の2つのURLを使い分けます。構成の詳細はNeonとは、Supabaseでの接続文字列の分け方はSupabaseにPrismaとDrizzleをつなぐで扱っています。
2026年に公表された4件のSQLインジェクション脆弱性と更新すべき版
「型安全だから安全」とは限りません。2026年3月から5月にかけて、Kysely本体に深刻度highの脆弱性が4件公表されました。
JSONパスとsql.litで見つかった脆弱性の範囲と修正版の一覧
GitHub Advisory Databaseで2026年10月29日に確認した内容を表にしました。
| 識別子 | 公表日 | 影響する版 | 修正版 |
|---|---|---|---|
| CVE-2026-32763 | 3月18日 | 0.26.0〜0.28.11 | 0.28.12 |
| CVE-2026-33442 | 3月20日 | 0.28.12〜0.28.13 | 0.28.14 |
| CVE-2026-33468 | 3月20日 | 0.28.13以前 | 0.28.14 |
| CVE-2026-44635 | 5月11日 | 0.26.0〜0.28.16 | 0.28.17 |
原因は、JSON列のパス指定に入るキー名と、sql.lit()などで文字列をSQLへ直接埋め込む処理のエスケープ不足でした。CVE-2026-32763とCVE-2026-44635はJSONパス、CVE-2026-33468はMySQLでのバックスラッシュの扱いが原因です。4件とも0.28.17以上で修正されており、0.29系はすべて影響範囲の外にあります。既存案件ではnpm ls kyselyで、間接依存として入っている版まで確認してください。
Kysely<any>と型エラーの握りつぶしで危険が広がる理由
CVE-2026-32763の題名には「コンパイルエラーを無視・抑止した場合、またはKysely<any>を使った場合」という条件が付いています。型が正しく効いていれば、JSONパスのキーは既知の列名に限られます。anyや@ts-expect-errorで型検査を外し、利用者の入力をキー名に流した箇所が攻撃の入口になりました。
実務での対策は2つです。1つ目は、マイグレーション以外でKysely<any>を使わないこと。2つ目は、sql.lit()・sql.raw()・sql.ref()に外部入力を渡していないかを、レビューの観点として固定することです。並べ替え列のように入力で列名を切り替えたい場合は、許可する列名の一覧と照合してから渡します。攻撃の典型例とテスト方法はSQLインジェクションとはで解説しています。
KyselyとPrisma・Drizzle・Knexの選定軸と案件体制・DB管理
TypeScriptのデータアクセス層でよく比べられる4つを、スキーマの持ち方と運用の手間で比べます。
スキーマの正・マイグレーション・型生成で比べる4ライブラリの比較表
| 観点 | Kysely | Drizzle ORM | Prisma | Knex |
|---|---|---|---|---|
| スキーマの正 | DBの実物 | TSのスキーマ定義 | schema.prisma | DBの実物 |
| 型の作り方 | 手書きか生成 | 定義から推論 | 定義から生成 | 型付けは弱い |
| マイグレーション | 手書き | 差分から生成 | 差分から生成 | 手書き |
| 関連の読み込み | JSON集約を自分で書く | relations定義で可 | includeで可 | 自分で書く |
| 書き方 | SQLに近い | SQLに近い+ORM風 | 独自のAPI | SQLに近い |
KyselyとKnexは「データベースが正」という点で同じ側にいます。KnexからKyselyへの移行は、書き方がほぼ同じまま型が付く改修として進めやすい組み合わせです。DrizzleとPrismaは「コードの定義が正」で、マイグレーションの生成まで面倒を見ます。Drizzleの使い方はDrizzle ORMとは、PrismaとTypeORMの比較はTypeORMとPrismaの違いと選び方、Prismaの版の状況はPrismaのバージョン一覧にまとめています。
採用条件はSQLを書けるチームが既存DBに型を付けたい開発案件
Kyselyを選ぶのは、次の条件が2つ以上当てはまる案件です。1つ目は、既存のデータベースがあり、テーブル設計をアプリ側のスキーマ定義に移したくない場合。DBAが別にいる、他システムと同じDBを共有している、といった状況がこれに当たります。2つ目は、ウィンドウ関数や共通テーブル式を多用する集計画面など、ORMのAPIでは書きにくいSQLが多い場合です。
3つ目は、Knexで書かれた既存コードに型を付けたい場合で、書き方の差が小さいため置き換えの工数を読みやすいのが理由です。4つ目は、発行されるSQLをコードレビューで確実に把握したい場合で、compile()で出力したSQLをそのまま実行計画の確認に回せます。0.29系で追加されたReadonlyKysely型を使えば、参照専用の画面から更新系のメソッドを呼べないことも型で保証できます。
見送る場面はリレーション中心のCRUDとSQLに不慣れな体制
Kyselyを選ばないのは次の3場面です。新規開発で、管理画面のようにテーブルの関連をたどるCRUDが大半を占めるなら、関連の読み込みを宣言で書けるDrizzleかPrismaが早く仕上がります。SQLを読めない開発者が多い体制では、Kyselyの型エラーがSQLの知識を前提にしているため、詰まったときに自力で抜けられません。Node.js 22へ上げられない実行環境なら、0.29系は使えないため、0.28.18に留めるか別の選択肢を検討します。
どのライブラリを選んでも、テーブル設計、インデックス、マイグレーションの順序と巻き戻し手順は、データアクセス層の外で決める仕事です。既存データベースの設計を見直しながらアプリの作り替えや移行を進めたい場合は、データベース設計・移行支援として要件の整理から相談いただけます。
よくある質問:Kyselyの読み方・無料利用・Drizzleとの違い・対応DB
Kyselyの導入を検討するときによく出る質問に、2026年10月29日時点の公式情報をもとに答えます。
Kyselyの読み方と名前の由来は何ですか?
公式のIntroductionでは「Key-Seh-Lee」と発音が示されており、日本語では「キーセリー」と読むのが近い表記です。名前の由来は公式ドキュメントに記載がありません。なお、フィンランド語の「kysely」は「問い合わせ」「アンケート」を意味する一般的な単語ですが、それが命名の理由かどうかは公式には書かれていません。検索するときは「kysely typescript」のように語を足すと、無関係なページを避けられます。
Kyselyは商用の業務システムでも無料で使えますか?
使えます。KyselyはMITライセンスで、有償版や機能制限版はありません。社内システムでも顧客向けのサービスでも同じ条件です。MITライセンスの条件を守るには、著作権表示とライセンス文を同梱してください。型生成のkysely-codegenやCLIのkysely-ctlも、それぞれのリポジトリで公開されているOSSです。
KyselyとDrizzle ORMの違いを一言で言うと何ですか?
スキーマの正をどこに置くかの違いです。Kyselyはデータベースの実物を正とし、型は後から合わせます。DrizzleはTypeScriptのスキーマ定義を正とし、そこからマイグレーションと型を作る設計です。クエリの書き味はどちらもSQLに近いため、既存DBに型を付けるならKysely、新規にDBごと設計するならDrizzleと分けると判断しやすくなります。
KyselyはMySQLやSQL Server、SQLiteでも使えますか?
使えます。組み込みのダイアレクトはPostgreSQL、MySQL、SQL Server、SQLite、PGliteの5種類です。MySQLはmysql2、SQL Serverはtediousとtarn、SQLiteはbetter-sqlite3をドライバーとして入れます。ほかにもコミュニティ製のダイアレクトがあり、サーバーレスのPostgres向けの接続方式にも対応できます。
0.28系から0.29系へ上げるときに何を直す必要がありますか?
主な作業は4つです。Migratorなどの読み込み元をkysely/migrationに変えること、実行環境をNode.js 22以上かつESMで読み込める形にすること、TypeScriptを5.4以上にすること、削除されたsql.valueとsql.literalをsql.valとsql.litに置き換えることです。すぐに上げられない場合も、脆弱性の修正を含む0.28.17以上には更新してください。
関連記事
- Drizzle ORM(ドリズル)とは?使い方・Prismaとの比較・v1.0 RCの変更点【2026年10月】:スキーマ定義をコード側に置くTypeScriptのORMとの比較先です
- TypeORMとPrismaの違いと選び方|型安全・発行SQLを実測【2026年10月】:モデル定義を持つORM同士の違いを発行SQLで比べています
- SupabaseにPrismaとDrizzleをつなぐ:接続文字列とマイグレーション二重管理の設計:プーラー経由と直接接続の使い分けを扱う同グループの記事です
- N+1問題とは?発行クエリ数の実測と検出方法・ORM別の対策:jsonArrayFromで避けるN+1の起き方と検出方法です
- SQLインジェクションとは?攻撃例と対策を実行結果で解説|テスト方法・試験の正答:sql.litやsql.rawに入力を渡したときの危険の背景です