Fix: Kysely Not Working — Type Errors on Queries, Migration Failing, or Generated Types Not Matching Schema
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Kysely query builder issues — database interface definition, dialect setup, type-safe joins and subqueries, migration runner, kysely-codegen for generated types, and common TypeScript errors.
The Problem
TypeScript shows a type error on a seemingly correct query:
const users = await db
.selectFrom('users')
.selectAll()
.execute();
// Type error: Property 'users' does not exist on type 'Database'Or a join produces unexpected type errors:
const result = await db
.selectFrom('users')
.innerJoin('posts', 'posts.user_id', 'users.id')
.select(['users.name', 'posts.title'])
.execute();
// result[0].name — TypeScript doesn't know if name comes from users or postsOr the migration runner can’t find migration files:
Error: Could not find any migrations at path: ./migrationsOr kysely-codegen generates types but they don’t match the actual database:
// Generated type has 'status' as string, but database uses an enum
// Resulting in type mismatches at runtimeWhy This Happens
Kysely is fully type-driven — TypeScript types are the source of truth for query construction:
Databaseinterface must match your schema exactly — every table and column must be declared. Kysely doesn’t introspect the database at build time (unless you usekysely-codegen). Missing or wrong types cause TypeScript errors.- Ambiguous column names require table qualification — after a join, columns from both tables are in scope. Selecting
'name'is ambiguous if both tables have anamecolumn. You must prefix with the table name:'users.name'. - Migrations need a configured provider — Kysely’s migration system needs a
FileMigrationProvider(or custom provider) pointing to the right directory and file pattern. - Generated types reflect the database at generation time — if you add columns or change types after running
kysely-codegen, the generated types become stale. Re-run codegen after schema changes.
The root model behind Kysely is that the Database interface and the runtime database are promises to match. Kysely doesn’t verify them at any point. If the Database interface says a column is string and the actual column is a Postgres text array, the query compiles, runs, and returns runtime values that don’t match the type at all — TypeScript will think you have strings while you have arrays. The bug then surfaces at the very first .toUpperCase() call thousands of lines downstream. The discipline this requires: never hand-write the Database interface for a non-trivial schema. Use kysely-codegen and re-run it on every schema change so the types track reality.
The second class of confusion is casing. Postgres columns are conventionally snake_case (user_id, created_at). JavaScript code is conventionally camelCase (userId, createdAt). Kysely is literal about column names — 'userId' does not match a column called user_id. There are three solutions: leave the types in snake_case and accept it in code, use kysely-codegen --camel-case to generate camelCase types backed by a runtime CamelCasePlugin, or write SQL aliases (.select(['user_id as userId'])). Inconsistency between these strategies inside one codebase causes “column does not exist” errors at runtime that TypeScript happily approved.
Diagnostic Timeline
A query fails to compile. The fix isn’t always “add the missing type.”
Minute 0–3. First wrong suspicion: “I need to check the type.” Open the type error message. If it says Property 'X' does not exist on type 'never', you’re joining a table that isn’t in the Database interface, and Kysely collapsed the result type to never. If it says Type 'string' is not assignable to type 'never' on an update, the ColumnType<..., ..., never> declaration is rejecting updates to that column. The error message tells you which case it is.
Minute 3–8. “Column does not exist” error at runtime. Open the actual SQL: console.log(query.compile()). If the SQL contains "userId" but your schema uses user_id, you’ve hit the casing trap. The fix is either to switch your Database interface to userId and add the CamelCasePlugin to the Kysely instance, or to keep snake_case throughout. Mixing both produces this exact error.
Minute 8–15. Generated types don’t match. First wrong suspicion: “regenerate.” Real cause is often that kysely-codegen ran against a development database that’s drifted from production. The dialect being inspected may have an older or newer schema than the one your app actually connects to. Point codegen at a fresh production-like dump (pg_dump --schema-only) and re-run. If you’re using Drizzle for migrations alongside Kysely for queries, regenerate both whenever migrations apply — drift between them is the most common bug class in mixed-tool projects.
Minute 15–25. Migrations don’t run. First wrong suspicion: “migration files are corrupt.” Real cause: FileMigrationProvider looks for files with specific extensions (.ts or .js) and a specific sort order. If your migration filenames don’t match the expected pattern (20240101_create_users.ts style), the provider ignores them. Run the migrator with logging:
console.log(await migrator.getMigrations());
// Returns the migrations the provider seesIf the array is empty, the path or pattern is wrong. If migrations are listed but unexpectedly skipped, check the kysely_migration tracking table — a partially applied migration may be marked as run without the schema actually changing.
Minute 25–35. Query returns wrong rows. Inspect query.compile() again. If parameter $1 is undefined, your .where('id', '=', userId) got an undefined value because the upstream resolver returned undefined. Kysely will happily pass undefined to PG, which compares it as null (never equal to anything in standard SQL). The fix is upstream: validate inputs before they reach the query builder.
Minute 35+. Last-resort cause: a Drizzle migration was applied that added a column Kysely’s interface doesn’t know about. selectAll() returns that column at runtime, but TypeScript thinks it doesn’t exist. Re-run kysely-codegen after every schema migration. Bake it into the CI step so codegen drift becomes a build failure instead of a runtime bug.
Fix 1: Define the Database Interface
// database.ts — the type definition for your entire database
import { ColumnType, Generated, Insertable, Selectable, Updateable } from 'kysely';
// Table interfaces — define every table and column
export interface UsersTable {
id: Generated<number>; // Auto-increment primary key
name: string;
email: string;
role: 'user' | 'admin'; // Union type for enums
bio: string | null; // Nullable column
created_at: ColumnType<Date, string | undefined, never>;
// ColumnType<SelectType, InsertType, UpdateType>
// Date when selecting, string when inserting (auto-set), never on update
}
export interface PostsTable {
id: Generated<number>;
user_id: number;
title: string;
content: string;
published: ColumnType<boolean, boolean | undefined, boolean>;
published_at: Date | null;
created_at: Generated<Date>;
}
export interface TagsTable {
id: Generated<number>;
name: string;
}
export interface PostTagsTable {
post_id: number;
tag_id: number;
}
// Combine into Database interface
export interface Database {
users: UsersTable;
posts: PostsTable;
tags: TagsTable;
post_tags: PostTagsTable;
}
// Generate TypeScript utility types from table interfaces
export type User = Selectable<UsersTable>; // Select result type
export type NewUser = Insertable<UsersTable>; // Insert type
export type UserUpdate = Updateable<UsersTable>; // Update typeCreate the Kysely instance:
// db.ts
import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';
import { Database } from './database';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
});
export const db = new Kysely<Database>({
dialect: new PostgresDialect({ pool }),
// log: ['query', 'error'], // Enable query logging
});Available dialects:
// PostgreSQL
import { PostgresDialect } from 'kysely';
import { Pool } from 'pg';
new PostgresDialect({ pool: new Pool({ connectionString: '...' }) });
// MySQL
import { MysqlDialect } from 'kysely';
import { createPool } from 'mysql2';
new MysqlDialect({ pool: createPool({ uri: '...' }) });
// SQLite (better-sqlite3)
import { SqliteDialect } from 'kysely';
import Database from 'better-sqlite3';
new SqliteDialect({ database: new Database('./db.sqlite') });
// Cloudflare D1 (use kysely-d1)
import { D1Dialect } from 'kysely-d1';
new D1Dialect({ database: env.DB });
// LibSQL/Turso
import { LibsqlDialect } from '@libsql/kysely-libsql';
new LibsqlDialect({ url: '...', authToken: '...' });Fix 2: Write Type-Safe Queries
import { db } from './db';
import type { NewUser, UserUpdate } from './database';
// SELECT — basic
const users = await db
.selectFrom('users')
.selectAll()
.execute();
// users: Selectable<UsersTable>[]
// SELECT specific columns
const names = await db
.selectFrom('users')
.select(['id', 'name', 'email'])
.execute();
// names: { id: number; name: string; email: string }[]
// WHERE clauses
const admins = await db
.selectFrom('users')
.selectAll()
.where('role', '=', 'admin')
.where('created_at', '>', new Date('2024-01-01'))
.execute();
// WHERE with OR
const results = await db
.selectFrom('users')
.selectAll()
.where((eb) => eb.or([
eb('name', 'like', '%alice%'),
eb('email', 'like', '%alice%'),
]))
.execute();
// INSERT
const newUser: NewUser = { name: 'Alice', email: '[email protected]', role: 'user' };
const inserted = await db
.insertInto('users')
.values(newUser)
.returningAll() // PostgreSQL — returns inserted row
.executeTakeFirstOrThrow();
// INSERT multiple
await db.insertInto('users').values([
{ name: 'Alice', email: '[email protected]', role: 'user' },
{ name: 'Bob', email: '[email protected]', role: 'user' },
]).execute();
// UPDATE
const update: UserUpdate = { name: 'Alice Smith' };
await db
.updateTable('users')
.set(update)
.where('id', '=', userId)
.execute();
// Or set individual fields
await db
.updateTable('users')
.set({ role: 'admin', bio: 'Updated bio' })
.where('id', '=', userId)
.execute();
// DELETE
await db
.deleteFrom('users')
.where('id', '=', userId)
.execute();
// UPSERT (insert or update)
await db
.insertInto('users')
.values({ id: 1, name: 'Alice', email: '[email protected]', role: 'user' })
.onConflict((oc) => oc
.column('email')
.doUpdateSet({ name: (eb) => eb.ref('excluded.name') })
)
.execute();Fix 3: Fix Joins and Subqueries
// INNER JOIN — all matching rows
const usersWithPosts = await db
.selectFrom('users')
.innerJoin('posts', 'posts.user_id', 'users.id')
// IMPORTANT: prefix columns with table name to avoid ambiguity
.select(['users.id', 'users.name', 'posts.title', 'posts.published_at'])
.where('posts.published', '=', true)
.execute();
// TypeScript knows exact types for each column
// LEFT JOIN — all users, posts if they exist
const usersWithOptionalPosts = await db
.selectFrom('users')
.leftJoin('posts', 'posts.user_id', 'users.id')
.select(['users.name', 'posts.title'])
// After leftJoin, posts.title is string | null (might not exist)
.execute();
// Multiple joins
const result = await db
.selectFrom('posts')
.innerJoin('users', 'users.id', 'posts.user_id')
.innerJoin('post_tags', 'post_tags.post_id', 'posts.id')
.innerJoin('tags', 'tags.id', 'post_tags.tag_id')
.select(['posts.title', 'users.name as author', 'tags.name as tag'])
.execute();
// Subquery in WHERE
const activeUsers = await db
.selectFrom('users')
.selectAll()
.where('id', 'in', (qb) =>
qb.selectFrom('posts')
.select('user_id')
.where('published', '=', true)
)
.execute();
// Subquery in SELECT (scalar)
const usersWithPostCount = await db
.selectFrom('users')
.select((eb) => [
'users.id',
'users.name',
eb.selectFrom('posts')
.whereRef('posts.user_id', '=', 'users.id')
.select(eb.fn.countAll<number>().as('count'))
.as('post_count'),
])
.execute();
// result[0].post_count: number
// Aggregate functions
const stats = await db
.selectFrom('posts')
.select((eb) => [
eb.fn.count<number>('id').as('total'),
eb.fn.avg<number>('view_count').as('avg_views'),
eb.fn.max('published_at').as('latest'),
])
.where('published', '=', true)
.executeTakeFirst();Fix 4: Run Migrations
// migrations/2024_01_01_create_users.ts
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('users')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('name', 'varchar(255)', (col) => col.notNull())
.addColumn('email', 'varchar(255)', (col) => col.notNull().unique())
.addColumn('role', 'varchar(50)', (col) => col.notNull().defaultTo('user'))
.addColumn('bio', 'text')
.addColumn('created_at', 'timestamp', (col) =>
col.defaultTo(sql`now()`).notNull()
)
.execute();
await db.schema
.createIndex('users_email_idx')
.on('users')
.column('email')
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('users').execute();
}// migrate.ts — run migrations
import { Kysely, Migrator, FileMigrationProvider } from 'kysely';
import { promises as fs } from 'fs';
import * as path from 'path';
import { db } from './db';
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(__dirname, 'migrations'),
}),
});
// Migrate to latest
async function migrateToLatest() {
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(`Failed to execute migration "${it.migrationName}"`);
}
});
if (error) {
console.error('Failed to migrate:', error);
process.exit(1);
}
await db.destroy();
}
// Rollback one step
async function rollback() {
const { error, results } = await migrator.migrateDown();
// ...
}
migrateToLatest();// package.json
{
"scripts": {
"migrate": "tsx src/migrate.ts",
"migrate:down": "tsx src/migrate-down.ts"
}
}Fix 5: Use kysely-codegen for Generated Types
Instead of writing Database manually, generate it from the actual database:
# Install
npm install --save-dev kysely-codegen
# Generate types from PostgreSQL
npx kysely-codegen --url postgresql://user:pass@localhost/mydb --out-file src/database.ts
# MySQL
npx kysely-codegen --url mysql://user:pass@localhost/mydb --out-file src/database.ts
# SQLite
npx kysely-codegen --url sqlite://./db.sqlite --out-file src/database.ts
# Add to package.json scripts
{
"scripts": {
"codegen": "kysely-codegen --url $DATABASE_URL --out-file src/database.ts"
}
}Configure codegen output:
# Camel case column names
npx kysely-codegen --url ... --camel-case --out-file src/database.ts
# Include only specific tables
npx kysely-codegen --url ... --include-pattern "users|posts|tags" --out-file src/database.ts
# Custom type for enums
npx kysely-codegen --url ... --out-file src/database.ts
# Then manually adjust the generated enum types if neededFix 6: Transaction and Error Handling
// Transactions
const result = await db.transaction().execute(async (trx) => {
const user = await trx
.insertInto('users')
.values({ name: 'Alice', email: '[email protected]', role: 'user' })
.returningAll()
.executeTakeFirstOrThrow();
await trx
.insertInto('posts')
.values({ user_id: user.id, title: 'First Post', content: 'Hello!', published: false })
.execute();
return user;
});
// If any query throws, the entire transaction is rolled back
// Set isolation level
await db.transaction().setIsolationLevel('serializable').execute(async (trx) => {
// ...
});
// executeTakeFirstOrThrow — throws if no rows
try {
const user = await db
.selectFrom('users')
.selectAll()
.where('id', '=', userId)
.executeTakeFirstOrThrow();
} catch (error) {
if (error instanceof NoResultError) {
throw new Error(`User ${userId} not found`);
}
throw error;
}Still Not Working?
TypeScript error: “Type ‘string’ is not assignable to type ‘never’” — this usually means you’re trying to update a column defined with ColumnType<..., ..., never>. The third type parameter of ColumnType is the update type. never means the column can’t be updated (e.g., auto-managed created_at). Remove that field from your update object.
Query runs but returns wrong results despite correct SQL — Kysely builds parameterized queries. If you’re debugging, log the actual SQL with parameters:
const query = db.selectFrom('users').selectAll().where('id', '=', userId);
console.log(query.compile());
// { sql: 'select * from "users" where "id" = $1', parameters: [123] }executeTakeFirst() returns undefined when rows exist — this happens if you’re selecting from the wrong table or have a WHERE clause that filters out all rows. Use execute() and check the array length, or use executeTakeFirstOrThrow() to get an error when no row is found. Also check that you’re awaiting the query — forgetting await returns the query builder, not the result.
CamelCasePlugin corrupts JSON column keys — the plugin converts both column names and nested object keys returned from json_agg / json_build_object. If your code expects { snake_case: 'value' } inside a JSON column but reads { snakeCase: 'value' } instead, the plugin is mid-transforming the payload. Either store the JSON pre-camelCased, or apply the plugin selectively with new CamelCasePlugin({ underscoreBeforeDigits: false }) and convert JSON in the application layer.
Drizzle migrations and Kysely queries drift — common in projects that use Drizzle for schema definition + migrations and Kysely for runtime queries. When Drizzle applies a migration that adds a column, the Kysely Database interface doesn’t know about it until you re-run kysely-codegen. Add a CI step that runs codegen after migrations and fails the build if the output differs from committed types.
Migrations run on Postgres but fail on SQLite — Kysely uses the dialect to translate schema operations, but advanced SQL features (CTEs, RETURNING, partial indexes) have different support across dialects. SQLite predates RETURNING in older versions and doesn’t support ALTER COLUMN. If your migration uses Postgres-specific features, run it against your actual deployment target before you trust it.
For related database issues, see Fix: Drizzle ORM Not Working, Fix: Prisma Migration Failed, Fix: SQLAlchemy Not Working, and Fix: Mongoose Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Neon Database Not Working — Connection Timeout, Branching Errors, or Serverless Driver Issues
How to fix Neon Postgres issues — connection string setup, serverless HTTP driver vs TCP, database branching, connection pooling, Drizzle and Prisma integration, and cold start optimization.
Fix: Turso Not Working — Connection Refused, Queries Returning Empty, or Embedded Replicas Not Syncing
How to fix Turso database issues — libsql client setup, connection URLs and auth tokens, embedded replicas for local-first apps, schema migrations, Drizzle ORM integration, and edge deployment.
Fix: Upstash Not Working — Redis Commands Failing, Rate Limiter Not Blocking, or QStash Messages Lost
How to fix Upstash issues — Redis REST client setup, rate limiting with @upstash/ratelimit, QStash message queues, Kafka topics, Vector search, and edge runtime integration.
Fix: Convex Not Working — Query Not Updating, Mutation Throwing Validation Error, or Action Timing Out
How to fix Convex backend issues — query/mutation/action patterns, schema validation, real-time reactivity, file storage, auth integration, and common TypeScript type errors.