Guide/guides

Relationships

Declare relationships next to the model that owns them, then eager-load them everywhere. Each relation is registered on the model class with hasMany(), belongsTo() or one of the other methods below. The models themselves are described in Models.

Relationships

Declare relationships next to the model that owns them, then eager-load them everywhere. Each relation is registered on the model class with hasMany(), belongsTo() or one of the other methods below. The models themselves are described in Models.

hasMany / belongsTo

// app/Models/User.ts
export class User extends defineModel(users) {
  static override relationTypes: { posts: HasManyRecord<PostRecord> } = {
    posts: [],
  }
}

User.hasMany('posts', () => import('./Post.js').then((m) => m.Post), 'authorId', 'id')

// app/Models/Post.ts
export class Post extends defineModel(posts) {
  static override relationTypes: { author: BelongsToRecord<UserRecord> } = {
    author: null,
  }
}

Post.belongsTo('author', () => import('./User.js').then((m) => m.User), 'authorId', 'id')

relationTypes tells TypeScript what each relation loads as; the values ([], null) are runtime placeholders. Typing loaded relations shows the declare form that needs none. The related model is passed as a function that imports it, so two models can refer to each other without a circular import.

Other relationship types

import { userRoles } from '@/db/schema'

User.hasOne('profile', Profile, 'userId', 'id')
User.belongsToMany('roles', Role, userRoles, 'userId', 'roleId')
Country.hasManyThrough('posts', Post, User, 'countryId', 'authorId')
Method Arguments
hasMany(name, Related, foreignKey, localKey) The foreign key on the related table, and this model's key it points at (usually id). Loads an array, [] when nothing matches
belongsTo(name, Related, foreignKey, ownerKey) The foreign key on this model, and the related model's key. Loads one record or null
hasOne(name, Related, foreignKey, localKey) As hasMany, but loads one record or null
belongsToMany(name, Related, pivotTable, foreignPivotKey, relatedPivotKey, parentKey?, relatedKey?) Many-to-many through a pivot table
hasManyThrough(name, Related, Through, firstKey, secondKey) Records reached through an intermediate model
morphMany(name, Related, morphName, localKey) / morphTo(name, morphName) Polymorphic relations, below

For belongsToMany, pivotTable is the Drizzle table object exported from @/db/schema, not the table's name. foreignPivotKey and relatedPivotKey are both columns on the pivot, referencing this model and the related one; parentKey and relatedKey are the local keys they point at, and both default to 'id'.

Polymorphic Relationships

Polymorphic relationships let a model belong to more than one type of parent using a single relation. For example, both posts and videos can have comments.

Define the type/id columns on the related table:

// db/schema.ts
export const comments = pgTable('comments', {
  id: serial('id').primaryKey(),
  body: text('body').notNull(),
  commentableType: text('commentable_type').notNull(),
  commentableId: integer('commentable_id').notNull(),
})

Register the relationships:

// Parent models
Post.morphMany('comments', Comment, 'commentable', 'id')
Video.morphMany('comments', Comment, 'commentable', 'id')

// Child model (inverse)
Comment.morphTo('commentable', 'commentable')

// Map type strings to model classes
Model.morphMap = {
  'Post': Post,
  'Video': Video,
}

Query them like any other relation:

const postWithComments = await Post.with('comments')
const comment = await Comment.findWith(1, 'commentable')
console.log(comment.commentable) // Post or Video record

Eager Loading

const users = await User.with('posts')             // users[0].posts is PostRecord[]
const posts = await Post.with('author')             // posts[0].author is UserRecord | null
const filtered = await Post.with('author', { authorId: [1, 2] })

Eager loading also works on the QueryBuilder, so you can combine it with filters and ordering. The record-returning methods (get(), first(), firstOrFail(), and paginate()) all attach the relations:

const activeUsers = await User.where('active', true)
  .with('posts')
  .orderBy('name')
  .get()

const user = await User.newQuery().with('posts').first()

// Each row of the page carries its author
const page = await Post.newQuery().with('author').orderBy('id', 'desc').paginate({ page: 1, perPage: 10 })

Nested relations

Nested relations use dot notation:

const users = await User.with('posts.comments')

Several paths may branch off the same relation. The shared head is read once, so every branch lands on the same records:

const users = await User.newQuery().with('posts.comments', 'posts.tags').get()

users[0].posts[0].comments // loaded
users[0].posts[0].tags     // loaded too

Constraining what loads

On the QueryBuilder, the object form of with() takes a callback per relation to constrain the query that loads it. The callback receives that relation's query builder with the foreign-key filter already on it, so a where() narrows which related rows load:

const users = await User.newQuery()
  .with({ posts: (q) => q.where('published', true) })
  .get()

users[0].posts // only the published posts

Each object key constrains exactly the level it names, and the order of the keys does not matter. A dotted key constrains the leaf and leaves the head unfiltered; list both keys to constrain both levels:

// `posts` loads unfiltered, `comments` are filtered
await User.newQuery()
  .with({ 'posts.comments': (q) => q.where('approved', true) })
  .get()

// both levels filtered
await User.newQuery()
  .with({
    posts: (q) => q.where('published', true),
    'posts.comments': (q) => q.where('approved', true),
  })
  .get()

rule

A top-level orWhere() inside a callback widens the query instead of narrowing it, because it ORs against the foreign-key filter the loader applied. Keep it grouped: q.where((g) => g.where('a', 1).orWhere('b', 2)).

A select() must include the column the relation is keyed on (the foreign key for hasMany/hasOne, the owner key for belongsTo). Without it the loader cannot match rows back to their parent and the relation loads empty.

note

Relations are loaded in batches rather than one query per parent, so limit() inside a constraint caps the whole result set, not each parent's share. A constraint carrying limit(), offset() or orderBy() is answered by one query over every key at once; without one, the keys are split into batches the driver's parameter limit admits. For belongsToMany and hasManyThrough the keys split that way are the related rows', not the parents'. For morphTo, the callback runs once per morph target, so it may only reference columns every target shares.

For belongsToMany and hasManyThrough, the callback constrains the query for the related model, not the pivot or through-table lookup that finds which rows to fetch. Filter on the related model's own columns:

// keeps the `news` tags of each post; the pivot lookup is untouched
await Post.newQuery()
  .with({ tags: (q) => q.where('label', 'news') })
  .get()

The static Model.with() is a different signature: its second argument filters the parent records, not the relation. Use Model.newQuery().with({ ... }) for constraint callbacks.

Typing loaded relations

Nested paths type only the head segment from relationTypes. To get typed children, declare the nested shape inside the head relation's record type:

export class User extends defineModel(users) {
  declare static relationTypes: {
    posts: HasManyRecord<PostRecord & { comments: CommentRecord[] }>
  }
}

const loaded = await User.with('posts.comments')
loaded[0].posts[0].comments // CommentRecord[] — typed end to end

note

Only the head segment (posts above) is checked against relationTypes: anything after the first dot is an unvalidated string, so a typo'd or malformed tail ('posts.', 'posts..comments', 'posts.typo') still compiles. At runtime, an unknown tail relation throws, but only once the loader actually has a loaded row to recurse into. If every record's head relation loads zero rows, the tail is never inspected and the call quietly no-ops instead of throwing. Nesting through a morphTo relation always throws at runtime regardless. That constraint isn't enforced at the type level either.

BelongsToRecord<T> is always T | null — the loader cannot know the row exists. When the foreign key is NOT NULL and the parent is guaranteed, declare the relation with BelongsToRequiredRecord<T> instead. Using the declare modifier skips the runtime placeholder value:

export class Comment extends defineModel(comments) {
  declare static relationTypes: {
    author: BelongsToRequiredRecord<UserRecord>
  }
}

const comments = await Comment.with('author')
comments[0].author.name // no null check required

Relation Counts

withCount() attaches a ${name}Count field without loading the related rows, ideal for list pages that only display totals:

const users = await User.withCount('posts')        // users[0].postsCount is number
const posts = await Post.withCount(['comments', 'author'], { published: true })

Supported for hasMany, hasOne, morphMany (children per record) and belongsTo (0 or 1).