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
| Criterion | BigAl | Kysely 0.29 |
|---|---|---|
| Kind | ORM with repositories and relationships | SQL query builder; no entities or relationships |
| Databases | PostgreSQL only | PostgreSQL, MySQL, SQLite, SQL Server, PGlite; more via community dialects |
| Query style | Fluent builder: find().where({...}).sort().limit() | SQL-shaped: selectFrom('products').where('price', '>=', 100) |
| Schema definition | Classes with @table and @column decorators | A Database interface, hand-written or generated by kysely-codegen |
| Migrations | None; BigAl issues no DDL, so pair it with a migration tool | Migrator runs hand-written up and down files; no diff generation |
| Relationships | model, collection, through; loaded with .populate() | Joins, or jsonArrayFrom and jsonObjectFrom to nest rows as JSON |
| JSONB | Property paths (->, ->>) and @> containment in .where() | Typed paths: eb.ref('col', '->>').key('theme') |
| DISTINCT ON | .distinctOn([...]) | .distinctOn(...) |
| ON CONFLICT | create() option: onConflict with ignore or merge | .onConflict((oc) => oc.column('sku').doUpdateSet(...)) or .doNothing() |
| pgvector | vector columns; nearestTo sorting and distance filters | sql 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() |
| CTEs | Not supported; use raw SQL | .with() and .withRecursive() |
| Type safety | From class properties; .select() and .populate() narrow results | Flows from the Database interface; selections and aliases shape results |
| Runtime deps | Zero; add postgres-pool, pg, or @neondatabase/serverless | Zero; add a driver such as pg |
| Runtimes | Node.js 22.11+, Bun, Deno 2; edge runtimes untested | Node.js 22+, Deno, Bun, and other JavaScript runtimes |
| Transactions | transaction() with isolation level and lock, statement, idle timeouts | db.transaction().execute() with isolation level; startTransaction() savepoints |
| Raw SQL | pool.query(), or query() on the transaction scope | sql 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(), anddestroy()that return typed records. - You want vector search and JSONB filters without extra helper packages.
- You want read replica routing and
beforeCreateandbeforeUpdatehooks 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