Models
This guide is the conceptual entry point for Lucid models. You will learn:
- What a Lucid model is and when to reach for one
- How to create a model and where its column definitions come from
- The conventions Lucid uses for tables, columns, primary keys, and timestamps
- The static properties that override those conventions
- How to create, find, update, and delete records (with pointers to the deep references)
- How models track state through their lifecycle
- How to switch transactions and connections at runtime
Overview
A Lucid model is a TypeScript class that maps to a database table. Each instance of the model represents a single row from that table. The class exposes methods to query the table, and instances expose methods to save and delete individual rows. This pattern is called Active Record.
import User from '#models/user'
const user = await User.findOrFail(1)
user.email = 'virk@adonisjs.com'
await user.save()
Lucid models are the default way to query and persist data. The model query builder exposes the same query capabilities as the database query builder (filtering, joins, aggregates, set operations, CTEs) and maps every row in the result back to a typed model instance. On top of those queries, models also give you lifecycle hooks, relationships, computed properties, and serialization rules that live on the model class alongside the data.
Use the database query builder directly only when the result cannot be a model instance. The two common cases are queries that do not need model instances (one-off reports, scripts) and queries that join multiple tables and project columns not defined on any single model.
Creating a model
Models live in app/models and are scaffolded with make:model.
node ace make:model User
// CREATE: app/models/user.ts
The most useful flags generate related artifacts in one command:
--migrationgenerates a matching migration--factorygenerates a matching factory--controllergenerates a resourceful controller--transformergenerates a matching transformer
node ace make:model Post --migration --factory --controller
See the commands reference for the full flag list.
The generated model is intentionally empty. All column definitions come from the auto-generated schema class, not the model file.
Anatomy of a model
A new Lucid model extends a schema class generated by Lucid from your live database tables. The schema class carries the column definitions; the model class carries the behavior.
import { UsersSchema } from '#database/schema'
export default class User extends UsersSchema {}
The corresponding generated schema class looks like this:
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'
export class UsersSchema extends BaseModel {
static table = 'users'
@column({ isPrimary: true })
declare id: number
@column()
declare email: string
@column({ serializeAs: null })
declare password: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}
The schema class is regenerated every time migrations run, so any code you add to it is lost. Keep your custom code in the model file instead. This includes relationships, hooks, query scopes, computed properties, custom methods, and serialization overrides. The schema class defines the column structure, and the model class handles everything else.
For the full schema generation workflow, type mappings, and customization, see the schema classes guide.
Conventions
Lucid follows a set of naming conventions so that most models work without configuration. Each convention can be overridden through the static properties documented in the next section.
| Concept | Default | Example |
|---|---|---|
| Table name | Snake-cased, pluralized model name | User → users, BlogPost → blog_posts |
| Primary key column | id | id |
| Column properties | Snake-case columns become camelCase properties | created_at → createdAt |
| Foreign key on relations | Snake-case singular model name + _id | User belongs through user_id |
| Local key on relations | The model's primaryKey | id |
| Created/updated timestamps | createdAt and updatedAt columns marked with autoCreate / autoUpdate | Generated with table.timestamps(true, true) |
When the database does not match these conventions (existing schema, third-party tooling, multi-tenant column prefixes), override the specific convention that does not match.
Model configuration
Configure a model by setting static properties on the class. Every static property below has a sensible default; you only declare the ones you want to override.
-
table
-
The database table name. Defaults to the snake-cased, pluralized model class name. Override when your model points at a non-conventional table.
export default class User extends UsersSchema {static table = 'app_users'} -
primaryKey
-
The column that uniquely identifies a row. Defaults to
'id'. The primary key column is also used as the local key for relationships.export default class User extends UsersSchema {static primaryKey = 'user_id'} -
connection
-
The named database connection the model queries against. Defaults to the application's primary connection. Set this when a model lives on a non-default connection (analytics database, read replica pool, tenant database).
export default class AnalyticsEvent extends AnalyticsEventsSchema {static connection = 'analytics'}Lucid uses this value for every query the model issues, including relationships and
Model.transaction(...)calls. -
selfAssignPrimaryKey
-
Set to
truewhen your application generates the primary key value rather than the database. Defaults tofalse. Required for models that use UUID, ULID, or any application-generated identifier.export default class User extends UsersSchema {static selfAssignPrimaryKey = true}When
selfAssignPrimaryKeyistrue, Lucid passes the primary key in the INSERT statement and uses it for the returned row, rather than reading back an auto-generated value from the database.
Creating, finding, and updating instances
A quick tour of the most common model operations. The deep reference for each lives in the CRUD operations and query builder guides.
Creating a row. Model.create builds an instance, persists it, and returns it.
import type { HttpContext } from '@adonisjs/core/http'
import User from '#models/user'
export default class UsersController {
async store({ request }: HttpContext) {
return User.create({
email: request.input('email'),
password: request.input('password'),
})
}
}
Finding a row. Model.find returns the row with the given primary key, or null. Model.findOrFail throws when no row matches, which AdonisJS turns into a 404 response automatically.
const user = await User.findOrFail(params.id)
Updating a row. Mutate the model's properties, then call save. Lucid only writes the columns that actually changed.
const user = await User.findOrFail(params.id)
user.email = request.input('email')
await user.save()
Deleting a row. Call delete on a loaded instance.
const user = await User.findOrFail(params.id)
await user.delete()
Querying multiple rows. Model.query() returns a typed query builder scoped to the model.
const activeUsers = await User
.query()
.where('is_active', true)
.orderBy('createdAt', 'desc')
.limit(10)
For the full set of finders (findBy, findMany, firstOrCreate, updateOrCreate, createMany, the *Quietly variants) see CRUD operations. For the typed query builder including preloads, scopes, and aggregates, see the model query builder.
Model state
A Lucid model carries more than just its column values. Each instance also tracks whether the row has been persisted, which properties have been modified since the model was loaded, which relationships have been preloaded, and any extra columns returned by the query.
The easiest way to understand these state properties is to walk through the persistence lifecycle.
const user = new User() // local, not persisted, not dirty
user.email = 'virk@adonisjs.com' // dirty: { email }
user.password = 'secret' // dirty: { email, password }
await user.save() // INSERT runs; now persisted, dirty cleared
user.email = 'new@adonisjs.com' // dirty again: { email }
console.log(user.$original.email) // 'virk@adonisjs.com'
console.log(user.$attributes.email) // 'new@adonisjs.com'
await user.save() // UPDATE runs; only email is sent
await user.delete() // DELETE runs; isDeleted is set
Two pairs of properties drive change tracking:
$attributesholds the model's current column values. Assigning to a property likeuser.email = '...'updates$attributes.$originalholds the values the model was loaded with (or the values from the most recentsave). It is the baseline that$dirtycompares against.
The remaining properties mark the model's lifecycle state:
-
$dirty
-
Object containing only the columns that have changed since the model was loaded or last saved. Empty when nothing has changed.
-
$isDirty
-
truewhen$dirtyhas any keys. Convenient when you need a boolean check without reading the dirty object. -
$isPersisted
-
trueonce the model has been saved to the database (whether throughsave,create, or loaded from a query). Models loaded from queries are persisted by default. -
$isLocal
-
truefor models constructed in application code (withnew User()orModel.create) andfalsefor models loaded from the database. Useful forbeforeSavehooks that should only run for new records. -
$isNew
-
The opposite of
$isPersisted.truewhen the model has never been saved. -
$isDeleted
-
trueoncedelete()has been called and the row removed. Subsequent calls tosaveon a deleted model throw. -
$primaryKeyValue
-
The current value of the model's primary key column, regardless of which column the primary key is. Equivalent to
model[Model.primaryKey]. -
$extras
-
Extra columns returned by the query but not declared on the model. Joined columns and aggregates are stored here. For example,
withCount('posts')populatesmodel.$extras.posts_count. -
$preloaded
-
Object of relationships that have been loaded with
preloadorload, keyed by relationship name. -
$sideloaded
-
Object of arbitrary key-value pairs propagated through queries with the
sideloadmethod. Useful for attaching request-scoped context (current user, tenant ID) to every model in a query result. -
$trx
-
The transaction client this model is currently bound to, or
undefined. Set byuseTransactionor by reading the model from a transaction. -
$options
-
The current model options (connection name, profiler context). Mostly useful inside hooks and adapters.
Runtime customization
Three instance methods let you adjust how a single model interacts with the database without touching its static configuration.
refresh
Re-read the model's row from the database and overwrite the local attributes. Useful after a write that triggered database-side defaults, generated columns, or trigger-based mutations that the application code did not see.
const post = await Post.create({ title: 'Hello' })
// `slug` is generated by a database trigger
await post.refresh()
console.log(post.slug)
useTransaction
Bind the model instance to a transaction client so its writes participate in the transaction's commit or rollback. See the transactions guide for the full pattern.
await db.transaction(async (trx) => {
const user = new User()
user.email = 'virk@adonisjs.com'
user.useTransaction(trx)
await user.save()
})
When you load a model from a transaction-bound query, $trx is set automatically and you do not need to call useTransaction again on the result.
useConnection
Bind the model to a non-default connection at runtime. Use this for multi-tenant applications that resolve the tenant database per request, where setting static connection is not flexible enough.
import User from '#models/user'
export default class TenantService {
static forTenant(tenantConnection: string) {
const user = new User()
user.useConnection(tenantConnection)
return user
}
}
The setting applies to the model instance only; subsequent queries off User.query() continue to use the default connection.
Adding custom behavior
The generated schema class defines the columns your model has. The model file is where you add everything else: domain methods, derived properties, and state-dependent logic. Keep custom code here, not in the generated schema file, so your edits survive regeneration.
Custom methods
Instance methods let you express operations that belong with the model. They can read this.$attributes and the rest of the model state, perform validation, and call save or delete as needed.
import { DateTime } from 'luxon'
import { SubscriptionsSchema } from '#database/schema'
export default class Subscription extends SubscriptionsSchema {
isActive(): boolean {
return this.status === 'active' && this.expiresAt > DateTime.now()
}
async cancel(reason: string) {
this.status = 'cancelled'
this.cancellationReason = reason
this.cancelledAt = DateTime.now()
await this.save()
}
}
Static methods follow the same pattern and live on the class itself. Use them for behavior that operates on the model type rather than a specific instance.
import { SubscriptionsSchema } from '#database/schema'
export default class Subscription extends SubscriptionsSchema {
static activeForUser(userId: number) {
return this.query().where('user_id', userId).where('status', 'active')
}
}
Derived properties with getters
TypeScript getters let you expose derived values that are computed from existing columns. Getters do not hit the database and do not require a column to back them.
import { UsersSchema } from '#database/schema'
export default class User extends UsersSchema {
get fullName(): string {
return `${this.firstName} ${this.lastName}`.trim()
}
get initials(): string {
return `${this.firstName?.[0] ?? ''}${this.lastName?.[0] ?? ''}`.toUpperCase()
}
}
Getters that override an actual column (for example, returning a computed URL for a stored path) are a column-override pattern and belong in the schema classes guide rather than here.
Where to go next
Each focus area has its own guide:
- Schema classes covers how the auto-generated
database/schema.tsworks, type mappings for every database column type, schema rules for type customization, and model-level column overrides. - CRUD operations covers
create,createMany,findOrFail,firstOrCreate,updateOrCreate, the*Quietlyvariants, and the full save and delete API. - Model query builder covers typed model queries, preloads, aggregates, has and whereHas filtering, sideload, and
pojofor plain-object reads. - Query scopes covers reusable query helpers attached to the model class.
- Hooks covers lifecycle hooks (
beforeSave,afterCreate,beforeDelete, and others) for enforcing invariants and triggering side effects. - Serializing models covers how models become JSON for API responses through AdonisJS transformers, with realistic patterns for lists, relationships, and paginated data.
For relationship modeling (belongsTo, hasMany, manyToMany, hasManyThrough, hasOne), see the Relationships category.