Better Auth
Use better-drizzle as the Better Auth database adapter, so auth reads and writes run through your plugins, hooks, and errors.
better-drizzle/auth is a Better Auth database adapter built on your better-drizzle client. It plays the same role as Better Auth's built-in Drizzle, Prisma, and MongoDB adapters, but every auth query goes through the client you already configured:
- Plugins see auth writes: timestamps stamps sessions, uuid generates ids, soft delete hides deleted users from sign-in, cache invalidates after session writes, and zod, ata, and rules validate them.
- Hooks and metadata run for auth operations:
beforeQuery,afterQuery,onError, transaction hooks, and$withContext()metadata. - Errors arrive as
BetterDrizzleErrorwith the code, constraint, and table, not as raw driver errors. - Joins load through the batched relation loader, one query per relation.
The adapter reads the dialect from the client and the tables and relations from drizzle({ relations }), so it needs no provider or schema option.
Experimental
The Better Auth adapter is experimental in 0.4.x. Its options can change
in a patch release. Pin an exact version if you depend on it. See
stability.
Install
npm install better-drizzle better-auth
npm install -D @better-auth/drizzle-adapterpnpm add better-drizzle better-auth
pnpm add -D @better-auth/drizzle-adapteryarn add better-drizzle better-auth
yarn add -D @better-auth/drizzle-adapterbun add better-drizzle better-auth
bun add -D @better-auth/drizzle-adapterbetter-auth ^1.7.0 is an optional peer dependency of better-drizzle, so only apps that import better-drizzle/auth install it. @better-auth/drizzle-adapter is needed only by npx auth generate, which uses its Drizzle schema generator.
Setup
Pass the better-drizzle client to adapter() and use the result as database:
import { betterAuth } from 'better-auth';
import { adapter } from 'better-drizzle/auth';
import { db } from './db';
export const auth = betterAuth({
database: adapter(db),
emailAndPassword: { enabled: true },
});adapter is also exported as betterDrizzleAdapter, for codebases that prefer an explicit name next to other adapters.
Generate the schema
Better Auth needs its tables (user, session, account, verification, plus the tables of the Better Auth plugins you enable). With auth.ts in place, the Better Auth CLI writes them as a Drizzle schema:
npx auth@latest generate --output src/db/auth-schema.tsThe file holds the tables and an authRelations part built with defineRelationsPart, the same output as Better Auth's own Drizzle adapter for Drizzle ORM 1.x. For PostgreSQL it looks like this:
Apply it with your usual migrations (drizzle-kit generate and migrate, or drizzle-kit push). Better Auth's own npx auth migrate works only with its Kysely adapter.
Two generate options change the output, and only the output: camelCase: true keeps camelCase table and column names instead of snake_case, and schemaName puts the PostgreSQL tables in a pgSchema().
Register the tables and relations
better-drizzle reads tables and relations from drizzle({ relations }). A table missing from it is invisible to the adapter. defineRelationsPart only lists tables that have relations (here verification has none), so spread it over a defineRelations call that covers every table:
import { better } from 'better-drizzle';
import { defineRelations } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as authSchema from './auth-schema';
import * as schema from './schema';
const { authRelations, ...authTables } = authSchema;
const relations = {
...defineRelations({ ...schema, ...authTables }),
...authRelations,
};
export const db = better(
drizzle(process.env.DATABASE_URL ?? '', { relations }),
);import { better } from 'better-drizzle';
import { defineRelations } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/mysql2';
import * as authSchema from './auth-schema';
import * as schema from './schema';
const { authRelations, ...authTables } = authSchema;
const relations = {
...defineRelations({ ...schema, ...authTables }),
...authRelations,
};
export const db = better(
drizzle(process.env.DATABASE_URL ?? '', { relations }),
);import { Database } from 'bun:sqlite';
import { better } from 'better-drizzle';
import { defineRelations } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/bun-sqlite';
import * as authSchema from './auth-schema';
import * as schema from './schema';
const { authRelations, ...authTables } = authSchema;
const relations = {
...defineRelations({ ...schema, ...authTables }),
...authRelations,
};
export const db = better(
drizzle({ client: new Database('app.db'), relations }),
);import { better } from 'better-drizzle';
import { defineRelations } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as authSchema from './auth-schema';
import * as schema from './schema';
const { authRelations, ...authTables } = authSchema;
const relations = {
...defineRelations({ ...schema, ...authTables }),
...authRelations,
};
export const db = better(
drizzle(process.env.DATABASE_URL ?? '', { relations }),
);import { better } from 'better-drizzle';
import { defineRelations } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/mysql2';
import * as authSchema from './auth-schema';
import * as schema from './schema';
const { authRelations, ...authTables } = authSchema;
const relations = {
...defineRelations({ ...schema, ...authTables }),
...authRelations,
};
export const db = better(
drizzle(process.env.DATABASE_URL ?? '', { relations }),
);import { Database } from 'bun:sqlite';
import { better } from 'better-drizzle';
import { defineRelations } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/bun-sqlite';
import * as authSchema from './auth-schema';
import * as schema from './schema';
const { authRelations, ...authTables } = authSchema;
const relations = {
...defineRelations({ ...schema, ...authTables }),
...authRelations,
};
export const db = better(
drizzle({ client: new Database('app.db'), relations }),
);If your app relations already use defineRelations(schema, (r) => ...), keep that call and spread authRelations after it; the auth tables still need to be in the object passed to defineRelations.
Options
Every option is optional.
| Option | Default | Description |
|---|---|---|
transaction | true | run multi-step Better Auth writes (sign-up creates a user and an account) in one db.transaction(); nested calls use savepoints |
meta | undefined | metadata merged into every auth operation, like $withContext() |
cache | false | let auth reads use the cache plugin; auth writes invalidate either way |
usePlural | detected | table keys are plural (users, sessions); true when the client has a users table and no user table |
debugLogs | false | Better Auth adapter debug logs |
camelCase | false | npx auth generate only: camelCase table and column names |
schemaName | undefined | npx auth generate only: PostgreSQL schema for the generated tables |
import { betterAuth } from 'better-auth';
import { adapter } from 'better-drizzle/auth';
export const auth = betterAuth({
database: adapter(client, {
meta: { source: 'better-auth' },
transaction: true,
}),
});Better Auth's own modelName and fields options keep working: the adapter resolves a model by table key or database table name, and fields by column key.
How auth calls map
Each Better Auth call becomes one delegate call on the table, so it runs the same plugin pipeline, hooks, and scopes as your own code:
| Better Auth | better-drizzle |
|---|---|
create | create({ data, select }) |
findOne | findFirst({ where, select / include }) |
findMany | findMany({ where, take, skip, orderBy, select / include }) |
count | count({ where }) |
update | update({ where, data }), which changes at most one row |
updateMany, deleteMany | updateMany / deleteMany, returning the count |
delete | delete({ where }) |
consumeOne | delete({ where }) returning the row; MySQL locks it first with FOR UPDATE |
incrementOne | update with { increment }; the where guard is rechecked in the same statement |
transaction | db.transaction() |
Where clauses map to filters: eq, ne, lt, lte, gt, gte, in, not_in, contains, starts_with, and ends_with, with mode: 'insensitive' and null checks. Pattern operators match % and _ literally. A where field or select field that is not a column throws INVALID_ARGS instead of being ignored.
Joins
With advanced.database.joins: true, Better Auth asks for related rows in the same call, for example the user of a session. The adapter turns each join into an include on the batched relation loader, so reading a session with its user is two queries, with no db.query call:
import { betterAuth } from 'better-auth';
import { adapter } from 'better-drizzle/auth';
export const auth = betterAuth({
advanced: { database: { joins: true } },
database: adapter(client),
});A join needs the matching relation in drizzle({ relations }), which authRelations provides. When a relation is missing, the call throws INVALID_ARGS naming the two tables and columns, instead of falling back to separate queries without a warning. Without joins, Better Auth loads related rows with separate adapter calls and no relation is required.
Plugins
Auth operations run through every plugin on the client.
Timestamps and UUID ids
timestamps() sets createdAt and updatedAt on auth rows from its own clock. With uuids() on a model's id, the adapter leaves the id to the plugin instead of Better Auth's random string, so auth rows get the same UUIDv7 ids as the rest of your data:
import { better } from 'better-drizzle';
import { adapter } from 'better-drizzle/auth';
import { timestamps } from 'better-drizzle/timestamps';
import { uuids } from 'better-drizzle/uuid';
const client = better(db, { plugins: [uuids(), timestamps()] });
export const database = adapter(client);Ids Better Auth derives itself (single-use records) and advanced.database.generateId still win over the plugin.
Soft delete
With softDelete() and a deletedAt column on user, a deleted user is hidden from every auth read, so sign-in and session lookups treat it as missing. Deleting a user through Better Auth (deleteUser) becomes a soft delete. Sessions and accounts are deleted for real unless they also have the column.
Tenant
Auth tables are shared by every tenant. Add them to global in tenant():
import { better } from 'better-drizzle';
import { tenant } from 'better-drizzle/tenant';
const client = better(db, {
plugins: [
tenant({
columns: 'organizationId',
global: ['user', 'session', 'account', 'verification'],
}),
],
});If an auth table is still scoped, Better Auth fails to initialize with INVALID_ARGS naming the table, instead of returning nothing for every user without a tenant. Passing a $withoutTenant({ reason }) client to adapter() is allowed.
Cache
Sessions change on every sign-in and refresh, so auth reads skip the cache by default. Writes still invalidate it, so your own cached reads of session or user stay correct. Set cache: true to cache auth reads with the plugin's settings.
Validation and rules
zod and ata validate auth writes like any other write, and rules apply to them. Validation must accept the values Better Auth writes, including fields added by its plugins and additionalFields.
Hooks, metadata, and transactions
Client hooks run for auth operations, and meta tells them apart from your own calls:
import { better } from 'better-drizzle';
import { adapter } from 'better-drizzle/auth';
const client = better(db, {
hooks: {
afterQuery({ action, meta, table }) {
if (meta?.source === 'better-auth') console.log('auth read', table, action);
},
},
});
export const database = adapter(client, { meta: { source: 'better-auth' } });With transaction: true, Better Auth's multi-step writes run in db.transaction(), so transaction hooks and afterCommit callbacks fire for them, and the cache plugin invalidates on commit.
Errors
Every adapter failure is a BetterDrizzleError, including driver errors that a client without hooks would otherwise pass through untouched. A duplicate email is a DATABASE_ERROR that isUniqueViolation() recognizes, with constraint and table set. Better Auth endpoints still answer with their own API errors; the client onError hook receives the BetterDrizzleError of a failed delegate call for logging.
Compared with the Drizzle adapter
better-drizzle/auth | @better-auth/drizzle-adapter | |
|---|---|---|
| Setup | adapter(db) | drizzleAdapter(db, { provider, schema }) |
| Plugins, hooks, metadata | run for auth calls | bypassed |
| Errors | BetterDrizzleError | raw driver errors |
| Joins | batched loader, one query per relation | db.query |
| Missing join relation | INVALID_ARGS | logs and falls back |
| Unknown where field | INVALID_ARGS | BetterAuthError |
| Transactions | on by default | off by default |
| MySQL insert read-back | by primary key or insertId | id, unique fields, or every field |
| Schema generation | same generator | Drizzle ORM 1.x generator |
On SQLite, both adapters return the same shapes for the hot auth calls (session with user join, create session, update session, delete expired sessions); bun run bench:auth checks the results and then measures both.
Database notes
- PostgreSQL:
generateId: 'uuid'relies on a database default forid(uuid().defaultRandom()), as with the Drizzle adapter.string[]andnumber[]fields use native arrays. - MySQL: there is no
RETURNING, so writes read the row back by primary key. Equality follows the column collation; the default MySQL collations are case-insensitive, so use a_binor_cscollation where Better Auth needs case-sensitive matches (the Drizzle adapter behaves the same). - SQLite: date columns can be
integer({ mode: 'timestamp_ms' })or text; text dates are written as ISO 8601 and read back asDate.
The adapter passes Better Auth's adapter test suite (@better-auth/test-utils) on SQLite, PostgreSQL, and MySQL, including the transaction, join, UUID, numeric id, case-insensitive, and full sign-up and sign-in flows.