Skip to Content
📝 QueryingTransactions

Last Updated: 3/20/2026


Transactions

Transactions group multiple queries into a single atomic unit. If any query fails, all changes are rolled back. Kysely provides two transaction APIs: automatic transactions with db.transaction() and controlled transactions with db.startTransaction().

Automatic Transactions

The transaction() method creates a transaction that automatically commits or rolls back based on whether the callback succeeds or throws:

const catto = await db.transaction().execute(async (trx) => { const jennifer = await trx.insertInto('person') .values({ first_name: 'Jennifer', last_name: 'Aniston', age: 40, }) .returning('id') .executeTakeFirstOrThrow() return await trx.insertInto('pet') .values({ owner_id: jennifer.id, name: 'Catto', species: 'cat', }) .returningAll() .executeTakeFirst() })

The callback receives a Transaction<DB> object (which extends Kysely<DB>). All queries executed through trx run inside the transaction.

If the callback throws, the transaction is rolled back and the error is re-thrown. If the callback returns normally, the transaction is committed.

This pattern is implemented in the TransactionBuilder class (see src/kysely.ts). The builder provides setIsolationLevel and setAccessMode methods for configuring the transaction before execution.

Transaction Isolation Levels

Set the isolation level with setIsolationLevel:

await db .transaction() .setIsolationLevel('serializable') .execute(async (trx) => { // Queries run at SERIALIZABLE isolation await trx.insertInto('person').values({ first_name: 'Jennifer' }).execute() })

Supported isolation levels (defined in src/driver/driver.ts):

  • 'read uncommitted'
  • 'read committed'
  • 'repeatable read'
  • 'serializable'
  • 'snapshot' (SQL Server only)

Not all databases support all isolation levels. Check your database documentation.

Access Modes

Set the access mode with setAccessMode:

await db .transaction() .setAccessMode('read only') .execute(async (trx) => { // Read-only transaction const people = await trx.selectFrom('person').selectAll().execute() })

Supported access modes:

  • 'read write' (default)
  • 'read only'

Read-only transactions can improve performance and prevent accidental writes.

Controlled Transactions

The startTransaction() method creates a transaction that you commit or rollback manually. This gives you full control over transaction lifetime:

const trx = await db.startTransaction().execute() try { const jennifer = await trx.insertInto('person') .values({ first_name: 'Jennifer', last_name: 'Aniston', age: 40, }) .returning('id') .executeTakeFirstOrThrow() const catto = await trx.insertInto('pet') .values({ owner_id: jennifer.id, name: 'Catto', species: 'cat', }) .returningAll() .executeTakeFirstOrThrow() await trx.commit().execute() } catch (error) { await trx.rollback().execute() throw error }

The startTransaction() method returns a ControlledTransaction<DB> object (see src/kysely.ts). This extends Transaction<DB> and adds commit(), rollback(), and savepoint methods.

Why Controlled Transactions Prevent Mistakes

Once a controlled transaction is committed or rolled back, it becomes unusable. Any attempt to run a query throws an error:

const trx = await db.startTransaction().execute() await trx.insertInto('person').values({ first_name: 'Jennifer' }).execute() await trx.commit().execute() // ❌ Error: Transaction is already committed await trx.insertInto('person').values({ first_name: 'Arnold' }).execute()

This prevents a common bug: accidentally running queries outside the transaction after it’s been committed. The implementation uses a NotCommittedOrRolledBackAssertingExecutor wrapper (see src/kysely.ts) that checks transaction state before executing each query.

The ControlledTransaction class tracks state with two boolean flags:

interface ControlledTransctionState { isCommitted: boolean isRolledBack: boolean }

Every query execution checks these flags and throws if either is true.

Savepoints

Savepoints create nested transaction boundaries. You can roll back to a savepoint without aborting the entire transaction.

Creating Savepoints

The savepoint() method creates a savepoint and returns a new ControlledTransaction instance:

const trx = await db.startTransaction().execute() try { const jennifer = await trx .insertInto('person') .values({ first_name: 'Jennifer', last_name: 'Aniston', age: 40, }) .returning('id') .executeTakeFirstOrThrow() const trxAfterJennifer = await trx.savepoint('after_jennifer').execute() try { const catto = await trxAfterJennifer .insertInto('pet') .values({ owner_id: jennifer.id, name: 'Catto', species: 'cat', }) .returning('id') .executeTakeFirstOrThrow() await trxAfterJennifer .insertInto('toy') .values({ name: 'Bone', price: 1.99, pet_id: catto.id }) .execute() } catch (error) { // Roll back pet and toy inserts, but keep Jennifer await trxAfterJennifer.rollbackToSavepoint('after_jennifer').execute() } await trxAfterJennifer.releaseSavepoint('after_jennifer').execute() await trx.insertInto('audit').values({ action: 'added Jennifer' }).execute() await trx.commit().execute() } catch (error) { await trx.rollback().execute() throw error }

Savepoint Type Safety

Savepoints are type-safe. The ControlledTransaction class tracks savepoint names in a type parameter:

class ControlledTransaction<DB, S extends string[] = []>

When you call savepoint('after_jennifer'), the returned transaction has type ControlledTransaction<DB, ['after_jennifer']>. You can only roll back to or release savepoints that exist in the type:

const trx = await db.startTransaction().execute() const trx1 = await trx.savepoint('sp1').execute() // ✅ Valid await trx1.rollbackToSavepoint('sp1').execute() // ❌ Type error: 'sp2' is not in the savepoint list await trx1.rollbackToSavepoint('sp2').execute()

This prevents typos and ensures you only reference savepoints that have been created.

Rolling Back to Savepoints

The rollbackToSavepoint() method undoes all changes since the savepoint was created:

const trx = await db.startTransaction().execute() const trx1 = await trx.savepoint('sp1').execute() await trx1.insertInto('person').values({ first_name: 'Jennifer' }).execute() // Undo the insert await trx1.rollbackToSavepoint('sp1').execute() await trx.commit().execute()

The savepoint remains active after rollback. You can continue using the transaction and roll back to the same savepoint again if needed.

Releasing Savepoints

The releaseSavepoint() method removes a savepoint, freeing its resources:

const trx = await db.startTransaction().execute() const trx1 = await trx.savepoint('sp1').execute() await trx1.insertInto('person').values({ first_name: 'Jennifer' }).execute() // Commit the savepoint await trx1.releaseSavepoint('sp1').execute() await trx.commit().execute()

Releasing a savepoint doesn’t commit the transaction — it just removes the savepoint marker. The changes are still part of the outer transaction and will be committed or rolled back with it.

Distributed Locking for Migrations

Kysely’s migration system uses distributed locks to ensure migrations run exactly once, even when multiple application instances start simultaneously. This locking mechanism is implemented at the database level using the same transaction infrastructure.

The Migrator class (see src/migration/migrator.ts) uses the dialect adapter’s acquireMigrationLock and releaseMigrationLock methods to coordinate access to the migration table. These methods use database-specific locking primitives:

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

The locks are automatically released if the migration process crashes or the connection fails, preventing deadlocks.

Transactional DDL Support

Some databases (PostgreSQL, SQLite) support transactional DDL — schema changes can be rolled back if a transaction fails. Kysely detects this capability through the dialect adapter’s supportsTransactionalDdl property.

When transactional DDL is supported, the Migrator runs migrations inside a transaction. If a migration fails, all schema changes are rolled back:

// PostgreSQL: DDL is transactional await db.transaction().execute(async (trx) => { await trx.schema.createTable('person').addColumn('id', 'serial').execute() await trx.schema.createTable('pet').addColumn('id', 'serial').execute() // If this fails, both tables are rolled back throw new Error('Migration failed') })

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, which you must fix manually.

What’s Next

  • Migrations: Manage schema changes over time with Kysely’s Migrator class, file-based migrations, and distributed locking.