better-drizzle

Upgrading

Move to Drizzle ORM 1.x with defineRelations, or migrate from scoped plugin packages to the unified package.

Moving to Drizzle ORM 1.x

better-drizzle 0.3.0 supports only Drizzle ORM 1.x: the drizzle-orm peer range is >=1.0.0-rc.4 <1.0.0-rc.5, and the release is tested against 1.0.0-rc.4. Drizzle 1.x replaces the relations(...) helpers with defineRelations(...) (relational queries v2). better-drizzle now reads tables and relations from the Drizzle instance, so better() no longer takes a schema option.

Staying on drizzle-orm 0.x

The 0.2.x line is the last one that works with drizzle-orm 0.x (peer ^0.30.0). It keeps the better(db, { schema }) API. Pin better-drizzle@~0.2.0 until you upgrade Drizzle itself. There is no release that supports both major versions of Drizzle.

At a glance

0.2.x (Drizzle 0.x)0.3.x (Drizzle 1.x)
drizzle-orm peer^0.30.0>=1.0.0-rc.4 <1.0.0-rc.5
Relationsrelations(table, ...) per tableone defineRelations(schema, ...) call
Drizzle instancedrizzle(client, { schema })drizzle({ client, relations })
Clientbetter(db, { schema, ...options })better(db, options?)
Where tables come fromthe schema optiondb._.relations
Type parametertypeof schematypeof relations
Many-to-manyinferred junctions + relations.manyToManyexplicit .through() relations
Plugin / hook ctx.schemathe schema modulethe relations config
Driver errorsthrown as-iswrapped in DrizzleQueryError, driver error on cause

The delegate API keeps its methods, arguments, and result shapes, apart from the type changes and behavior changes listed below. That covers findMany, cursor, upsertMany, include._count, connect / set, locks, $withContext, raw SQL, transactions, and every plugin option. The migration touches the lines that build the client and the type annotations that name the schema.

1. Upgrade drizzle-orm

npm install drizzle-orm@1.0.0-rc.4 better-drizzle@^0.3.0

Follow Drizzle's own 1.x migration notes for driver and schema changes. The steps below cover what better-drizzle needs.

2. Move relations to defineRelations

Keep tables in schema.ts and declare every relation in one defineRelations call:

schema.ts
export const usersRelations = relations(users, ({ many }) => ({
	posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one }) => ({
	author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));
relations.ts
import { defineRelations } from 'drizzle-orm';
import * as schema from './schema';

export const relations = defineRelations(schema, (r) => ({
	users: { posts: r.many.posts() },
	posts: { author: r.one.users({ from: r.posts.authorId, to: r.users.id }) },
}));

Use defineRelations(schema) without a callback when the schema has no relations. When two relations connect the same pair of tables, give both sides the same alias. The old relationName option did this job.

3. Pass relations to drizzle() and drop schema from better()

db.ts
import { schema } from './schema';
const db = drizzle(pool, { schema });
export const client = better(db, { schema, plugins });
import { relations } from './relations';
const db = drizzle({ client: pool, relations });
export const client = better(db, { plugins });

better() reads tables and relations from db. Every other option - plugins, hooks, raw, locks, transaction - is unchanged, and the options argument is now optional: better(db). A Drizzle instance created without relations has no tables to read, so better() throws No tables found on the Drizzle instance. Pass your relations to drizzle(), e.g. drizzle({ client, relations: defineRelations(schema) }).

Only tables that appear in the relations config get a delegate. Pass the whole schema module to defineRelations(schema, ...), not only the tables that have relations.

4. Switch type generics to typeof relations

The client's type parameter is now the relations config:

type Db = BetterDrizzleClient<typeof schema>;
type Key = BetterTableKey<typeof schema>;
type UserWhere = WhereArg<typeof schema, 'users'>;
type Db = BetterDrizzleClient<typeof relations>;
type Key = BetterTableKey<typeof relations>;
type UserWhere = WhereArg<typeof relations, 'users'>;

The same applies to explicit better<...>() calls and to every exported helper type. The schema field of plugin setup and hook contexts is the relations config as well. It is keyed by table key, and each entry is { table, name, relations }, so plugin code that iterated the schema module and checked isTable(...) should read ctx.schema[key].table instead.

5. Replace many-to-many config with .through()

The relations: { inferManyToMany, manyToMany } option is removed, and junction tables are no longer inferred. Declare many-to-many relations with .through():

const client = better(db, {
	schema,
	relations: {
		manyToMany: [
			{ through: 'userGroups', left: { relation: 'user' }, right: { relation: 'group' } },
		],
	},
});

users: {
	groups: r.many.groups({
		from: r.users.id.through(r.userGroups.userId),
		to: r.groups.id.through(r.userGroups.groupId),
	}),
},

Relation filters, include / select, _count, and connect / disconnect / set work through .through() relations. A junction you relied on through inference now needs an explicit .through() relation. Without one, the relation does not exist and using it is a type error. See relations.

6. Check error handling and plugin column requirements

  • Drizzle 1.x wraps driver errors in DrizzleQueryError (Failed query: ...) with the driver error as cause. BetterDrizzleError.from(...), getDatabaseErrorInfo, and isUniqueViolation / isForeignKeyViolation / isNotNullViolation / isCheckViolation read the wrapped driver error, so codes and constraint names still resolve. Code that inspects raw driver errors directly, such as error.code === '23505' in a catch around raw Drizzle calls, should read error.cause.
  • Column dataType is now "<type> <constraint>" (for example number int32, object date, string uuid). A plugin's config.requires.columns[].type matches the column's columnType (PgTimestamp), its full dataType, or one part of it ('date', 'string'). Your own plugin code that compares column.dataType === 'string' must compare the first part instead: column.dataType.startsWith('string').
  • PostgreSQL array columns are now the element column with dimensions > 0; Drizzle 1.x has no PgArray column class or baseColumn. Plugin code that detected arrays with instanceof PgArray should check column.dimensions.
  • JSON path filters require jsonb. At the type level Drizzle 1.x no longer tells json from jsonb, so a path filter on a json column type-checks but throws at runtime: JSON path filters require a jsonb column; json columns only support whole-document filters.
  • On Bun SQLite, Drizzle 1.x requires raw db.transaction callbacks to be synchronous. better-drizzle's client.transaction(async (tx) => ...) still accepts async callbacks.

7. Typecheck

npx tsc --noEmit

Leftover schema options, typeof schema generics, and manyToMany config fail the typecheck.

The delegate argument types are also stricter in 0.3.0. Unknown keys are rejected at the top level and inside where, data, create, update, select, include, and orderBy, and so is select combined with include at the same level. Previously TypeScript let them through. A typo such as { where: { emial: '...' } } is now a compile error. When a check fails, remove the key or fix its name.

Other type changes the typecheck surfaces:

  • OrderType was removed from better-drizzle and better-drizzle/zod. Write 'asc' / 'desc' instead of OrderType.Asc / OrderType.Desc.
  • cursor() after / before accept only a cursor object or null. String cursors are a type error; the runtime already rejected them. nextCursor and previousCursor are now typed as that cursor object (or null), so you can pass them straight back without a cast. See cursor pagination.
  • create({ skipDuplicates }) is typed nullable. When skipDuplicates is set (and not false), the result type includes null, matching the runtime result for a skipped insert. Handle the null case.
  • update() and delete() return ThrowingWriteResult. The type no longer exposes .explain(), which writes never supported at runtime. .throw() is unchanged.

Breaking plugin API changes

  • better-drizzle/rules: mergeRules was removed. Use merge({ extends, rules }). extends takes one config or an array applied in order, and rules holds overrides applied last. Falsy entries in extends are skipped. See rules.
rules(mergeRules(safe(), { noRawMutation: 'warn' }));
rules(merge({ extends: [safe()], rules: { noRawMutation: 'warn' } }));

Behavior changes to review

These do not show up in the typecheck, but they can change what your code observes at runtime:

  • softDelete() filters more operations. In 0.2.x only findMany, findFirst, count, and exists hid deleted rows. Now findUnique, findOne, paginate, cursor, update, updateMany, updateEach, delete, and deleteMany do too. An update({ where: { id } }) that hits a soft-deleted row now matches nothing; pass deleted: 'with' to keep the old behavior. deleteMany() is now a soft delete instead of a physical one, and returns the count of rows it marked. Writes with an empty where stay no-ops, and mode: 'hard' still matches deleted rows. The requireTenantOnDelete, noHardDeleteOnSoftDeleteModel, and requireHardDeleteReason rules now also check deleteMany. See soft delete.

  • Reads are lazy. findMany, findFirst, findOne, findUnique, count, exists, paginate, and cursor return lazy thenables, like Drizzle's own query builders. The query and its hooks run when the result is awaited (or passed to then, catch, finally, or Promise.all), and at most once. A read that is created but never awaited no longer runs. .explain() alone never runs the read. Bun's expect(...).resolves / .rejects only accept native promises, so wrap reads with Promise.resolve(...) there. See testing.

  • .explain() output. Cursor probes that depend on a row from the data query are reported under deferredProbes instead of being executed, and a statement that only runs under a condition carries a condition string. See explain.

  • Text-backed timestamps. timestamps() and softDelete() write ISO 8601 strings to columns whose dataType is a string (such as SQLite text columns) and Date objects to native date columns.

  • Failures after COMMIT. When an afterCommit callback or an afterTransactionCommit hook throws, better-drizzle no longer runs rollback handling. There is no ROLLBACK on SQLite and no afterRollback or onTransactionError hook. The original error is rethrown as-is, and the data stays committed.

  • mode: 'insensitive' on equality. equals, in, notIn, and a scalar not used to ignore mode and stayed case-sensitive. They now compare lower(column) with lower(value) on every dialect, including JSONB paths and array element predicates. A param() list in in / notIn stays PostgreSQL-only.

  • MySQL upsert. The native ON DUPLICATE KEY UPDATE path is used only when the table declares no unique key besides the primary key. MySQL fires that clause on any unique key, so other tables use a read-then-write path instead of possibly updating a different row.

  • Native upsert on unique keys. A where that holds only the columns of one unique key, with the same values in create, now runs one ON CONFLICT ... DO UPDATE statement on PostgreSQL and SQLite (and ON DUPLICATE KEY UPDATE on MySQL when that key is the only one that can match). A where without the primary key no longer takes the primary key path when create omits it too: it used to insert without checking where, and now reads first.

  • update() and delete() change at most one row. In 0.2.x they ran their where against every matching row and returned only the first. Now only one row changes, on every dialect: a where that pins the primary key or a unique key runs as a plain statement, and any other where is limited to one row (a LIMIT 1 subquery on PostgreSQL and SQLite; on MySQL, a SELECT ... FOR UPDATE and a write by primary key in the active or an implicit transaction). Code that relied on update / delete touching several rows must use updateMany / deleteMany. The upsert read-then-write path also updates the row it found. See update and delete.

  • cursor() without orderBy pages by primary key ascending. Before, the default direction depended on the call (descending, or ascending with before), so the first page and later pages could walk different orders. Pass an explicit orderBy to keep a descending order. See cursor pagination.

  • Zod and ATA cursor schemas reject string cursors. $zod and $ata cursor args schemas accept only the cursor object or null, matching the types, so a string after / before fails validation instead of reaching the runtime.

  • Zod and ATA type numeric "number mode" columns as number. numeric(..., { mode: 'number' }) and decimal(..., { mode: 'number' }) columns validate as numbers, not strings. Columns in the default string mode still validate as strings. See zod and ata.

  • mode: 'insensitive' works on SQLite and MySQL. It compiled to ILIKE everywhere, which is a syntax error outside PostgreSQL. SQLite and MySQL now compile to lower(column) LIKE lower(pattern); PostgreSQL keeps ILIKE. See string filters.

  • Raw SQL rules also check $executeRaw. requireRawComment, requireRawTimeout, noRawMutation, and noRawWithoutTransaction used to check only $raw. Existing $executeRaw calls can now report violations. See rules.

  • Library error codes are kept. Operation, raw, and transaction wrappers keep the code of a BetterDrizzleError thrown inside them (for example from a hook or a nested call) instead of replacing it with OPERATION_ERROR. Check catch blocks that matched OPERATION_ERROR for those cases. See errors.

  • Invalid arguments throw INVALID_ARGS. Input validation failures, such as unknown columns or relations, select with include, page with skip, before with after, an invalid batchSize, duplicate updateEach by values, malformed atomic, array, or JSONB mutations, relation command errors, and Zod plugin parse failures, used OPERATION_ERROR (status 500). They now use INVALID_ARGS (status 400). Update catch blocks that matched OPERATION_ERROR for them. See errors.

  • Success callbacks all run. Every afterCommit callback, every afterTransactionCommit hook, and every plugin afterRaw hook now runs even when an earlier one throws; the first error is still rethrown. Previously the first failure skipped the rest.

  • findUnique requires a unique where. The where must pin one row: an equality on every primary key column, on a unique column, or on every column of a composite unique key (a param() counts). Any other where, including one where an undefined value dropped the key, such as { id: undefined }, throws UNIQUE_WHERE_REQUIRED before any SQL. Use findFirst for lookups by non-unique columns.

Also new in 0.3.0

  • Prepared statements: mark values with param(name), call .prepare(name?) on findUnique, findFirst, findOne, findMany, count, exists, paginate, or cursor, and run it with execute(values). Param and result types come from the read (PreparedParams / PreparedResult). Plugins and beforeQuery run once at prepare time; result hooks and intercepts run on every execution.
  • better-drizzle/cache (experimental): versioned read caching with automatic invalidation and a Redis store at better-drizzle/cache/redis.
  • better-drizzle/ata (experimental): JSON Schema validation of operation inputs and results, built on ata-validator and intended as a faster alternative to better-drizzle/zod. The entrypoint exports ata (default and named), its types, and version; the registry, column, row, where, and query-schema builders it uses internally are not exported.
  • $where(): every delegate compiles a typed where into a Drizzle SQL condition, so the same filter works in raw db.select() queries, joins, and subqueries.
  • Atomic updates: increment, decrement, multiply, divide, and set on number columns, and toggle on boolean columns.
  • PostgreSQL arrays: typed array filters, some / every / none element predicates, and append, prepend, remove, replace, and addUnique mutations.
  • JSONB paths: dotted path filters such as { metadata: { 'profile.age': { gte: 18 } } }, in / notIn on string and number paths, mode: 'insensitive' on string paths (examples), and path mutations in update, updateMany, updateEach, upsert, and upsertMany that compile to jsonb_set(...).
  • NULL ordering: orderBy accepts { direction, nulls: 'first' | 'last' }. See reads.
  • paginate() accepts page and perPage.
  • createMany batchSize: splits large inserts into one statement per chunk, like upsertMany. See createMany.
  • upsertMany on MySQL through ON DUPLICATE KEY UPDATE. See upsertMany details.
  • Plugin intercepts: intercept(ctx) wraps execution after transforms, so a plugin can serve a result without SQL or annotate after hooks.
  • Timestamps now and models options: an injectable clock for deterministic tests, and per-model column names or false to turn the plugin off for a model.

Not supported with Drizzle 1.x

better-drizzle cannot load or filter by relations with a relation-level where, or by one relations declared through a junction. Loading one throws Relation "x" on "t" cannot be loaded: ..., and filtering through one throws ... cannot be filtered: .... They still work with Drizzle's own db.query API on the same db. See limitations.

Plugin authors

  • ctx.schema in setup, hooks, transforms, and extensions is the relations config. See step 4.
  • Declare typed per-table model extensions with an interface extending ModelExtensionTypeResolver. Generic function resolvers still work, but with Drizzle 1.x types they can hit Type instantiation is excessively deep (TS2589). See writing plugins.
  • config.requires.columns now enforces optional and type. An optional column may be missing (it used to fail bootstrap anyway), and a type that does not match the column fails bootstrap with PLUGIN_REQUIRED_COLUMN_TYPE; type used to be informational. See setup and fail-fast requirements.
  • Plugins can add typed raw SQL options by augmenting the RawOptionsExtensions interface of better-drizzle. Raw hooks receive unknown options unchanged in rawOptions; the cache plugin's cache: { invalidate } raw option uses this.
  • better-drizzle/zod needs a Zod version with Zod 4-compatible schema types. The peer range is ^3.25.0 || ^4.0.0.

Upgrading to 0.2

0.2.0 collapses the plugin packages into the main package. The runtime API did not change - only where you install and import the official plugins from.

What changed

Before 0.2.0, each official plugin was published on its own:

npm install better-drizzle @better-drizzle/rules @better-drizzle/zod

From 0.2.0 on there is exactly one published package. The plugins are subpath exports of it:

npm install better-drizzle
Before (0.1.x)After (0.2.x)
@better-drizzle/eslintbetter-drizzle/eslint
@better-drizzle/rulesbetter-drizzle/rules
@better-drizzle/soft-deletebetter-drizzle/soft-delete
@better-drizzle/timestampsbetter-drizzle/timestamps
@better-drizzle/zodbetter-drizzle/zod
-better-drizzle/plugins (new - plugin authoring API)

Every entrypoint ships ESM, CommonJS, and its own declaration files. The subpaths are declared in exports, so bundlers and Node resolve them without a paths alias or any build-step configuration.

Migrating

1. Drop the scoped packages

npm uninstall @better-drizzle/eslint @better-drizzle/rules \
	@better-drizzle/soft-delete @better-drizzle/timestamps @better-drizzle/zod
npm install better-drizzle@^0.2.0

2. Rewrite the imports

Only the module specifier changes - the exported names are identical:

db.ts
import { recommended, rules } from '@better-drizzle/rules';
import { softDelete } from '@better-drizzle/soft-delete';
import { timestamps } from '@better-drizzle/timestamps';
import { recommended, rules } from 'better-drizzle/rules';
import { softDelete } from 'better-drizzle/soft-delete';
import { timestamps } from 'better-drizzle/timestamps';

import { better } from 'better-drizzle';

export const client = better(db, {
	schema,
	plugins: [rules(recommended()), timestamps(), softDelete()],
});

A one-shot rewrite across the codebase:

grep -rl "@better-drizzle/" src \
	| xargs sed -i "s|'@better-drizzle/|'better-drizzle/|g"

3. Update the ESLint config

The ESLint plugin moved the same way:

eslint.config.js
import betterDrizzle from '@better-drizzle/eslint';
import betterDrizzle from 'better-drizzle/eslint';

export default [betterDrizzle.configs.recommended];

4. Reinstall and typecheck

npm install && npx tsc --noEmit

Any remaining @better-drizzle/* specifier will fail to resolve, which makes the typecheck the complete checklist for this migration.

What did not change

  • better(db, options) and every option it accepts
  • every delegate method and its result shape
  • plugin factories, their options, and the typed operation args they add
  • hooks, transforms, transactions, raw SQL, and error shapes
  • the drizzle-orm@^0.30.0 and typescript@^5 peer ranges

If your code compiled on 0.1.x, changing the import specifiers is the entire upgrade.

Versioning from here

The single package means a single version line. better-drizzle and every plugin entrypoint always report the same version:

import { version } from 'better-drizzle';
import { version as zodVersion } from 'better-drizzle/zod';

version === zodVersion; // true

See stability & semver for what the 0.3.x line guarantees.

On this page