Skip to Content
⚙️ Schema & MigrationsMigrations

Last Updated: 3/20/2026


Migrations

Migrations are versioned scripts that evolve your database schema over time. Kysely’s Migrator class provides a safe, distributed-lock-protected system for running migrations across multiple application instances.

Migration File Structure

A migration file exports two functions: up for applying the migration and down for reverting it:

import { Kysely } from 'kysely' export async function up(db: Kysely<any>): Promise<void> { await db.schema .createTable('person') .addColumn('id', 'serial', (col) => col.primaryKey()) .addColumn('first_name', 'varchar(255)', (col) => col.notNull()) .addColumn('last_name', 'varchar(255)') .execute() } export async function down(db: Kysely<any>): Promise<void> { await db.schema.dropTable('person').execute() }

Important: Use Kysely<any>, not Kysely<YourDatabase>. Migrations must be frozen in time — they should not depend on your application’s current database types, which may change as the schema evolves.

The db parameter is a Kysely instance you can use to run queries or schema operations. Migrations can modify the schema (using db.schema) or manipulate data (using db.selectFrom, db.insertInto, etc.).

The down Function is Optional

If you don’t provide a down function, the migration is skipped when migrating down:

export async function up(db: Kysely<any>): Promise<void> { await db.schema.createTable('person').addColumn('id', 'serial').execute() } // No down function - this migration cannot be reverted

This is useful for migrations that are difficult or impossible to reverse (like data transformations that lose information).

Execution Order

Migrations run in alpha-numeric order by filename. A common convention is to prefix filenames with an ISO 8601 timestamp:

migrations/ 2024-01-15T10-30-00_create_person_table.ts 2024-01-15T11-00-00_create_pet_table.ts 2024-01-16T09-00-00_add_age_to_person.ts

The Migrator class (see src/migration/migrator.ts) sorts migration names using localeCompare by default. You can provide a custom comparator via the nameComparator option.

Ordered vs. Unordered Migrations

By default, Kysely enforces that migrations run in the same order they were created. If you have executed migrations A, B, and C, and then add migration A2 (which sorts between A and B), the migrator will throw an error:

corrupted migrations: expected previously executed migration B to be at index 1 but A2 was found in its place

This safety check prevents accidentally running migrations out of order, which can lead to schema inconsistencies.

Allowing Unordered Migrations

In large teams, multiple developers may create migrations in parallel. To support this workflow, set allowUnorderedMigrations: true:

const migrator = new Migrator({ db, provider: new FileMigrationProvider({ fs, path, migrationFolder: 'migrations', }), allowUnorderedMigrations: true, })

With this option enabled:

  • Migrating up: Pending migrations run in alpha-numeric order (regardless of when previously executed migrations were created).
  • Migrating down: Migrations are undone in reverse execution order (sorted by execution timestamp).

This is implemented in the Migrator#ensureMigrationsInOrder method, which is skipped when allowUnorderedMigrations is true.

The FileMigrationProvider

The FileMigrationProvider class (see src/migration/file-migration-provider.ts) reads migration files from a folder on disk:

import { promises as fs } from 'node:fs' import path from 'node:path' import { FileMigrationProvider, Migrator } from 'kysely' const migrator = new Migrator({ db, provider: new FileMigrationProvider({ fs, path, migrationFolder: path.join(__dirname, 'migrations'), }), })

The provider scans the folder for .js, .ts, .mjs, and .mts files (excluding .d.ts and .d.mts). It imports each file and extracts the up and down functions.

The migrationFolder must be an absolute path. Use path.join(__dirname, 'migrations') or path.resolve('migrations') to construct it.

Custom Migration Providers

You can implement your own MigrationProvider to load migrations from anywhere (a database, a remote API, etc.). The interface is simple:

interface MigrationProvider { getMigrations(): Promise<Record<string, Migration>> } interface Migration { up(db: Kysely<any>): Promise<void> down?(db: Kysely<any>): Promise<void> }

Return an object where keys are migration names and values are Migration objects. Kysely will sort the keys and run the migrations in order.

Running Migrations

The Migrator class provides several methods for running migrations:

migrateToLatest

Runs all pending migrations:

const { error, results } = await migrator.migrateToLatest() results?.forEach((it) => { if (it.status === 'Success') { console.log(`migration "${it.migrationName}" was executed successfully`) } else if (it.status === 'Error') { console.error(`failed to execute migration "${it.migrationName}"`) } }) if (error) { console.error('failed to migrate') console.error(error) process.exit(1) }

The method returns a MigrationResultSet object:

interface MigrationResultSet { readonly error?: unknown readonly results?: MigrationResult[] } interface MigrationResult { readonly migrationName: string readonly direction: 'Up' | 'Down' readonly status: 'Success' | 'Error' | 'NotExecuted' }
  • error — Defined if something went wrong (a migration failed, or an error occurred before migrations could run).
  • results — An array of results for each migration that was supposed to run. If a migration fails, its status is 'Error' and all subsequent migrations have status 'NotExecuted'.

The migrateToLatest method never throws — it always returns a result object. This makes error handling explicit and prevents uncaught exceptions.

migrateTo

Migrates up or down to a specific migration:

await migrator.migrateTo('2024-01-15T11-00-00_create_pet_table')

If the target migration has already been executed, this migrates down to it (undoing all migrations after it). If the target migration is pending, this migrates up to it.

To migrate all the way down (undo all migrations), use the NO_MIGRATIONS constant:

import { NO_MIGRATIONS } from 'kysely' await migrator.migrateTo(NO_MIGRATIONS)

migrateUp / migrateDown

Migrate one step up or down:

await migrator.migrateUp() // Run the next pending migration await migrator.migrateDown() // Undo the most recent migration

Distributed Locking

The Migrator uses database-level locks to ensure migrations run exactly once, even when multiple application instances start simultaneously. This prevents race conditions where two instances try to run the same migration.

The locking mechanism is implemented in the dialect adapter (see src/dialect/dialect-adapter.ts). Each database uses its own locking primitive:

  • PostgreSQL: Advisory locks (pg_advisory_lock)
  • MySQL: Named locks (GET_LOCK)
  • SQLite: Exclusive transactions
  • SQL Server: Application locks (sp_getapplock)

The lock is acquired before checking which migrations to run and released after all migrations complete (or fail). If the migration process crashes or the connection fails, the database automatically releases the lock.

The lock table is created automatically by the Migrator. By default it’s named kysely_migration_lock, but you can customize this with the migrationLockTableName option.

Migration Tables

The Migrator creates two internal tables:

kysely_migration

Stores the list of executed migrations:

interface MigrationTable { name: string // Migration name timestamp: string // ISO 8601 execution time }

The table is created automatically the first time you run migrations. You can customize the table name with the migrationTableName option:

const migrator = new Migrator({ db, provider, migrationTableName: 'my_migrations', })

kysely_migration_lock

Stores the distributed lock:

interface MigrationLockTable { id: string // Always 'migration_lock' is_locked: number // 0 or 1 }

This table is also created automatically. Customize the name with migrationLockTableName:

const migrator = new Migrator({ db, provider, migrationLockTableName: 'my_migration_lock', })

Migration Table Schema

You can specify a schema for the migration tables (PostgreSQL, SQL Server):

const migrator = new Migrator({ db, provider, migrationTableSchema: 'admin', })

This creates the tables in the admin schema instead of the default schema. The schema is created automatically if it doesn’t exist.

Transactional Migrations

On databases that support transactional DDL (PostgreSQL, SQLite), migrations run inside a transaction. If a migration fails, all schema changes are rolled back.

On databases that don’t support transactional DDL (MySQL, SQL Server), migrations run in a connection without a transaction. Failed migrations leave the database in a partially migrated state.

You can disable transactional migrations even on databases that support them:

const migrator = new Migrator({ db, provider, disableTransactions: true, })

This is useful when a migration includes queries that cannot run inside a transaction (like CREATE INDEX CONCURRENTLY in PostgreSQL).

Example Migration Script

Here’s a complete migration script you can add to your project:

import { promises as fs } from 'node:fs' import path from 'node:path' import { Pool } from 'pg' import { Kysely, Migrator, PostgresDialect, FileMigrationProvider } from 'kysely' async function migrateToLatest() { const db = new Kysely<any>({ dialect: new PostgresDialect({ pool: new Pool({ host: 'localhost', database: 'my_database', }), }), }) const migrator = new Migrator({ db, provider: new FileMigrationProvider({ fs, path, migrationFolder: path.join(__dirname, 'migrations'), }), }) const { error, results } = await migrator.migrateToLatest() results?.forEach((it) => { if (it.status === 'Success') { console.log(`migration "${it.migrationName}" was executed successfully`) } else if (it.status === 'Error') { console.error(`failed to execute migration "${it.migrationName}"`) } }) if (error) { console.error('failed to migrate') console.error(error) process.exit(1) } await db.destroy() } migrateToLatest()

Run this script before starting your application to ensure the database schema is up to date.

What’s Next

  • Installation: Learn how to install Kysely and the database driver for your chosen database.
  • Defining Database Types: Define TypeScript interfaces that map your database schema so Kysely can validate every query.
  • First Query: Write your first type-safe SELECT query.
  • Schema Builder: Learn the full db.schema API for creating tables, adding columns, and managing indexes.