Introduction

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:

  • --migration generates a matching migration
  • --factory generates a matching factory
  • --controller generates a resourceful controller
  • --transformer generates 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.

app/models/user.ts
import { UsersSchema } from '#database/schema'
export default class User extends UsersSchema {}

The corresponding generated schema class looks like this:

database/schema.ts (excerpt)
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.

ConceptDefaultExample
Table nameSnake-cased, pluralized model nameUserusers, BlogPostblog_posts
Primary key columnidid
Column propertiesSnake-case columns become camelCase propertiescreated_atcreatedAt
Foreign key on relationsSnake-case singular model name + _idUser belongs through user_id
Local key on relationsThe model's primaryKeyid
Created/updated timestampscreatedAt and updatedAt columns marked with autoCreate / autoUpdateGenerated 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 true when your application generates the primary key value rather than the database. Defaults to false. Required for models that use UUID, ULID, or any application-generated identifier.

export default class User extends UsersSchema {
static selfAssignPrimaryKey = true
}

When selfAssignPrimaryKey is true, 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.

app/controllers/users_controller.ts
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:

  • $attributes holds the model's current column values. Assigning to a property like user.email = '...' updates $attributes.
  • $original holds the values the model was loaded with (or the values from the most recent save). It is the baseline that $dirty compares 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

true when $dirty has any keys. Convenient when you need a boolean check without reading the dirty object.

$isPersisted

true once the model has been saved to the database (whether through save, create, or loaded from a query). Models loaded from queries are persisted by default.

$isLocal

true for models constructed in application code (with new User() or Model.create) and false for models loaded from the database. Useful for beforeSave hooks that should only run for new records.

$isNew

The opposite of $isPersisted. true when the model has never been saved.

$isDeleted

true once delete() has been called and the row removed. Subsequent calls to save on 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') populates model.$extras.posts_count.

$preloaded

Object of relationships that have been loaded with preload or load, keyed by relationship name.

$sideloaded

Object of arbitrary key-value pairs propagated through queries with the sideload method. 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 by useTransaction or 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.

app/services/tenant_service.ts
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.

app/models/subscription.ts
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.

app/models/user.ts
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.ts works, 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 *Quietly variants, and the full save and delete API.
  • Model query builder covers typed model queries, preloads, aggregates, has and whereHas filtering, sideload, and pojo for 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.