Database Migration System#
Better Auth's migration system generates and applies incremental database schema changes — new tables, new columns, and new indexes — through getMigrations(config) in packages/better-auth/src/db/get-migration.ts. This function is the single entry point used by both the better-auth migrate CLI command (direct execution) and the generate command (SQL file output) via the thin wrapper generateKyselySchema in packages/cli/src/generators/kysely.ts.
The migration system is Kysely-only. Other adapters (Prisma, Drizzle) use dedicated schema generators. If a non-Kysely adapter is detected,
getMigrationslogs an error and exits .
Pipeline Overview#
getSchema(config)
│
▼
db.introspection.getTables() ←── live DB state
│
▼
Diff tables/columns
│
├── toBeCreated → CREATE TABLE builders
└── toBeAdded → ALTER TABLE builders
│
▼
deferredIndexes → CREATE INDEX builders
│
▼
compileMigrations() / runMigrations()
-
Schema discovery —
getSchema(config)callsgetAuthTables(config)from@better-auth/core/dbto collect the union of all core and plugin table definitions. -
Table diffing —
db.introspection.getTables()introspects the live DB; tables are split intotoBeCreated(missing) andtoBeAdded(present but with missing columns) . -
CREATE TABLEgeneration — For eachtoBeCreatedentry, Kysely'sschema.createTable()is called. Columns receiveNOT NULL, foreign-key references, and — whenfield.uniqueis set — an inline.unique()constraint . -
ALTER TABLEgeneration — For eachtoBeAddedentry,schema.alterTable().addColumn()is called with the same column-level constraints . -
Deferred indexes —
CREATE INDEX/CREATE UNIQUE INDEXbuilders are collected indeferredIndexesand appended to the migration list after all table/column statements. This guarantees all referenced tables and columns exist before any index is created . -
SQL compilation —
compileMigrations()calls.compile().sqlon every builder and joins them with;\n\n.
Type Mapping#
getType(field, fieldName) translates DBFieldAttribute.type to a dialect-specific Kysely ColumnDataType. Key rules:
- MySQL
stringfields becomevarchar(255)whenunique,sortable, orindexis set; otherwisetext. datefields map totimestamptz(Postgres),timestamp(3)(MySQL),date(SQLite),datetime2(3)(MSSQL) .id/foreignKeyIdfields honouradvanced.database.generateIdto emituuid,integer GENERATED BY DEFAULT AS IDENTITY(Postgres), orvarchar(36).- PostgreSQL migrations additionally filter tables to the active
search_pathschema .
Compound / Multi-Column Indexes#
Introduced in PR #10402, tables can now declare named multi-column indexes in an indexes array alongside fields:
schema: {
compoundIdentity: {
fields: { issuer: { type: "string" }, providerSubject: { type: "string" } },
indexes: [
{ fields: ["issuer", "providerSubject"], name: "compound_identity_uidx", unique: true }
]
}
}
Each entry conforms to DBTableIndex (in packages/core/src/db/type.ts):
| Property | Type | Notes |
|---|---|---|
fields | [string, ...string[]] | 1–16 logical field names, in index order |
name | string? | ≤ 63 UTF-8 bytes; schema-wide unique; auto-hashed if longer |
unique | boolean? | Enforces tuple uniqueness; all fields must be required |
Validation rules :
json,string[],number[]fields cannot be indexed.- Unique indexes require all constituent fields to be non-nullable.
- MySQL/SQL Server deployments receive a blocking error (with remediation) if any
VARCHARcolumn in the index exceeds dialect byte limits (3 072 bytes MySQL, 1 700 bytes SQL Server).
For MongoDB, the adapter creates indexes lazily before the first write that enforces them, using a per-collection deduplication cache .
Known Issue: Redundant Unique Indexes (CREATE TABLE path)#
When a field carries both unique: true and index: true, the Kysely CREATE TABLE path currently emits two redundant uniqueness mechanisms :
- Inline column constraint —
.unique()at line 526–528 (unconditional whenfield.uniqueis true) - Deferred unique index —
CREATE UNIQUE INDEXpushed at lines 543–551 (triggered whenfield.index && field.unique)
A concrete trigger is the built-in organization.slug field, which carries { unique: true, index: true } . The ALTER TABLE path guards against this correctly — it only pushes a deferred index via the field.index check at lines 438–447 and does not redundantly set .unique() when field.index is true. The Drizzle generator received the equivalent CREATE TABLE fix in PR #10333. As of this writing, the CREATE TABLE path in get-migration.ts has not received the same guard — the minimal fix is if (field.unique && !field.index) at line 526.
Key Files#
| File | Role |
|---|---|
packages/better-auth/src/db/get-migration.ts | Core migration builder: schema diff, CREATE TABLE, ALTER TABLE, deferred indexes, compileMigrations() / runMigrations() |
packages/better-auth/src/db/get-schema.ts | Normalizes getAuthTables() output into migration-ready schema |
packages/cli/src/generators/kysely.ts | CLI entry point; calls getMigrations → compileMigrations → writes .sql file |
packages/core/src/db/type.ts | DBTableIndex interface; indexes property on schema table entries |
packages/core/src/db/database-index.ts | Index name generation, resolution, and validation helpers (added in PR #10402) |