Skip to content

BigAl vs Kysely ​

Kysely is a type-safe SQL query builder, not an ORM. You describe tables as TypeScript interfaces and write queries that mirror SQL. BigAl is a PostgreSQL ORM with decorator models, relationships, and .populate(). Choose Kysely for full SQL control across databases, including CTEs. Choose BigAl for repository-style CRUD with relationships on Postgres and less SQL to write.

This page compares BigAl 16 with Kysely 0.29, the current stable release.

At a glance ​

CriterionBigAlKysely 0.29
KindORM with repositories and relationshipsSQL query builder; no entities or relationships
DatabasesPostgreSQL onlyPostgreSQL, MySQL, SQLite, SQL Server, PGlite; more via community dialects
Query styleFluent builder: find().where({...}).sort().limit()SQL-shaped: selectFrom('products').where('price', '>=', 100)
Schema definitionClasses with @table and @column decoratorsA Database interface, hand-written or generated by kysely-codegen
MigrationsNone; BigAl issues no DDL, so pair it with a migration toolMigrator runs hand-written up and down files; no diff generation
Relationshipsmodel, collection, through; loaded with .populate()Joins, or jsonArrayFrom and jsonObjectFrom to nest rows as JSON
JSONBProperty paths (->, ->>) and @> containment in .where()Typed paths: eb.ref('col', '->>').key('theme')
DISTINCT ON.distinctOn([...]).distinctOn(...)
ON CONFLICTcreate() option: onConflict with ignore or merge.onConflict((oc) => oc.column('sku').doUpdateSet(...)) or .doNothing()
pgvectorvector columns; nearestTo sorting and distance filterssql template, or helpers from the pgvector/kysely package
Row locks.lock(): update, no key update, share, key share; nowait, skipLocked.forUpdate(), .forShare(), and key variants; .skipLocked(), .noWait()
CTEsNot supported; use raw SQL.with() and .withRecursive()
Type safetyFrom class properties; .select() and .populate() narrow resultsFlows from the Database interface; selections and aliases shape results
Runtime depsZero; add postgres-pool, pg, or @neondatabase/serverlessZero; add a driver such as pg
RuntimesNode.js 22.11+, Bun, Deno 2; edge runtimes untestedNode.js 22+, Deno, Bun, and other JavaScript runtimes
Transactionstransaction() with isolation level and lock, statement, idle timeoutsdb.transaction().execute() with isolation level; startTransaction() savepoints
Raw SQLpool.query(), or query() on the transaction scopesql tagged template, composable inside builder queries

When to choose BigAl ​

  • You want relationships defined once on the model and loaded with .populate(), instead of writing joins or JSON aggregation per query.
  • You want CRUD methods such as create(), update(), and destroy() that return typed records.
  • You want vector search and JSONB filters without extra helper packages.
  • You want read replica routing and beforeCreate and beforeUpdate hooks built in.

When to choose Kysely ​

  • You want every query to read like the SQL it runs, including CTEs, window functions with .over(), and complex joins.
  • You need MySQL, SQLite, or SQL Server, or you want one query style across them.
  • You prefer database types generated from the live schema over hand-written model classes.
  • You want built-in migration running and savepoints.

Migrating from Kysely ​

BigAl and Kysely can share one pg pool, so you can move one table at a time. Keep Kysely, or pool.query(), for CTEs and other queries BigAl does not model. See BigAl vs Raw SQL for that split.

Models ​

ts
import type { Generated } from 'kysely';

interface Database {
  products: {
    id: Generated<number>;
    name: string;
    sku: string;
    price_cents: number;
    store_id: number;
  };
  stores: { id: Generated<number>; name: string };
}
ts
import { column, Entity, primaryColumn, table } from 'bigal';
import type { Store } from './Store';

@table({ name: 'products' })
export class Product extends Entity {
  @primaryColumn({ type: 'integer' })
  public id!: number;

  @column({ type: 'string', required: true })
  public name!: string;

  @column({ type: 'string', required: true })
  public sku!: string;

  @column({ type: 'integer', required: true, name: 'price_cents' })
  public priceCents!: number;

  @column({ model: () => 'Store', name: 'store_id' })
  public store!: number | Store;
}

Queries and upserts ​

ts
const rows = await db
  .selectFrom('products')
  .innerJoin('stores', 'stores.id', 'products.store_id')
  .select(['products.id', 'products.name', 'stores.name as store_name'])
  .where('products.price_cents', '>=', 1000)
  .where('products.name', 'ilike', '%widget%')
  .orderBy('products.name')
  .limit(10)
  .execute();

await db
  .insertInto('products')
  .values({ sku: 'WDG-001', name: 'Widget', price_cents: 999, store_id: 1 })
  .onConflict((oc) => oc.column('sku').doUpdateSet((eb) => ({ price_cents: eb.ref('excluded.price_cents') })))
  .execute();
ts
const products = await productRepository
  .find()
  .where({ priceCents: { '>=': 1000 }, name: { contains: 'widget' } })
  .sort('name asc')
  .limit(10)
  .populate('store', { select: ['name'] });

await productRepository.create({ sku: 'WDG-001', name: 'Widget', priceCents: 999, store: 1 }, { onConflict: { action: 'merge', targets: ['sku'], merge: ['priceCents'] } });

Last reviewed: September 2026