Database Adapter Query Patterns#
Overview#
Better Auth's database layer is built around a DBAdapter interface with a uniform set of primitives (create, findOne, findMany, update, updateMany, delete, deleteMany, count, consumeOne, incrementOne). All adapters are constructed via the createAdapterFactory helper in packages/core/src/db/adapter/factory.ts, which wraps the adapter-specific implementation with field-name mapping, type coercion, and (optionally) join resolution.
The InternalAdapter — the consumer-facing API used by auth routes — lives in packages/better-auth/src/db/internal-adapter.ts and calls DBAdapter primitives through getWithHooks so database lifecycle hooks always fire.
Join Semantics#
Factory-Level Join Resolution#
The adapter factory controls how joins are dispatched. When options.experimental?.joins is disabled (the default), the factory intercepts findOne/findMany calls that carry a join clause and resolves them via handleFallbackJoin: a separate query is issued per joined model and the results are merged in memory. When experimental.joins is enabled, the join is passed through to the adapter implementation, which executes it natively .
The join clause is sanitized first by transformJoinClause, which:
- Inspects the schema for foreign keys between the base model and joined model.
- Determines join direction (forward vs. backward FK), derives
from/tocolumn names, and infers the relationship type (one-to-onevs.one-to-many) based on field uniqueness. - Assigns a default limit of
100(oroptions.advanced.database.defaultFindManyLimit) to one-to-many joins.
MongoDB Aggregation Pipeline#
The MongoDB adapter implements joins as aggregation pipelines . For each joined model:
- A
$lookupstage is added (either simple syntax or pipeline-syntax when a row limit is needed). - For one-to-one relationships (unique FK),
$unwindwithpreserveNullAndEmptyArrays: trueflattens the array to a single object. - For one-to-many relationships, the array is kept as-is.
This pipeline-based join is used for both findOne and findMany , making it natively supported when experimental.joins: true.
Kysely (SQL) LEFT JOIN#
The Kysely adapter uses SQL LEFT JOIN via Kysely's query builder . Joined columns are selected with a _joined_<model>_<field> prefix and then re-grouped by main-model id in processJoinedResults. One-to-many joins deduplicate by id and respect the configured limit.
Where Clause & Operator Support#
Both adapters translate the shared Where[] type into backend-specific query syntax:
- MongoDB:
convertWhereClausemaps eachWhereentry to MongoDB query operators ($eq,$in,$nin,$gt,$gte,$lt,$lte,$ne,$regex). Insensitive-mode string operators use$regexwith theiflag (viaquery-builders.ts). AND/OR connectors are mapped to$and/$or. - Kysely: Analogous helpers map operators to Kysely expression builder methods, with database-specific insensitive-mode handling (e.g.
ILIKEfor PostgreSQL).
The factory's transformWhereClause normalizes field names, coerces types (dates to ISO strings on databases without native date support, booleans to 0/1, numbers from strings), and applies any customTransformInput before handing the clause to the adapter.
Account Management Methods#
These are defined on createInternalAdapter in internal-adapter.ts:
| Method | Query pattern | Notes |
|---|---|---|
findAccounts | findMany on account where userId = ? | Returns all accounts for a user (no join) |
findAccountByProviderId | findOne on account where accountId = ? AND providerId = ? | Composite-key lookup |
findAccountByUserId | findMany on account where userId = ? | Alias for all accounts |
linkAccount | createWithHooks on account | Runs through database hooks; called during OAuth implicit linking |
deleteAccount | deleteWithHooks on account by id | Deletes by the id PK, not accountId |
deleteAccounts | deleteManyWithHooks on account where userId = ? | Bulk delete for user cleanup |
findOAuthUser — Multi-Step JOIN Pattern#
findOAuthUser is the core lookup for OAuth sign-in. It uses a join: { user: true } on the account model to fetch the user in a single query when the account is found . If the account is not found, it falls back to a user-by-email lookup followed by a separate accounts fetch . This mixed pattern avoids an extra round-trip on the hot path while handling the email-change edge case.
findSession — JOIN on session → user#
findSession uses join: { user: true } on the session model, fetching session + user in one query . When secondary storage is configured, the cache is consulted first and the DB join is bypassed.
Type Coercion & Capability Flags#
The factory applies coercions during transformInput and transformOutput based on adapter capability flags :
| Flag | Default | MongoDB | SQL (Kysely) |
|---|---|---|---|
supportsArrays | false | true — arrays stored natively | false — serialized to JSON strings |
supportsNumericIds | true | false — uses ObjectId/UUID | true |
supportsJSON | false | inherits default | depends on dialect |
supportsDates | true | true | varies |
MongoDB maps the id field to/from _id via mapKeysTransformInput/mapKeysTransformOutput , and uses customTransformInput/customTransformOutput to convert string IDs to ObjectId/UUID instances on write and back to hex strings on read .
Key Files#
| File | Purpose |
|---|---|
packages/core/src/db/adapter/factory.ts | Adapter factory: field mapping, type coercion, join resolution, all primitives |
packages/better-auth/src/db/internal-adapter.ts | High-level auth methods (findAccounts, linkAccount, findSession, etc.) |
packages/mongo-adapter/src/mongodb-adapter.ts | MongoDB adapter: aggregation pipeline joins, $lookup/$unwind, operator mapping |
packages/kysely-adapter/src/kysely-adapter.ts | Kysely adapter: SQL LEFT JOIN, processJoinedResults, MySQL RETURNING workaround |