Associations
Associations are declared as static properties using the marker functions: belongsTo, hasMany, hasOne, and habtm.
belongsTo
The owning side — holds the foreign key column.
// schema.ts
import { pgTable, integer, text } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
name: text('name').notNull(),
})
export const posts = pgTable('posts', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
title: text('title').notNull(),
userId: integer('user_id').notNull().references(() => users.id),
})// models/Post.model.ts
import { ApplicationRecord } from 'active-drizzle'
import { model } from 'active-drizzle'
import { belongsTo } from 'active-drizzle'
@model('posts')
export class Post extends ApplicationRecord {
static user = belongsTo()
// ActiveDrizzle infers: FK = userId, target table = users
}// Lazy load (returns a Promise)
const post = await Post.find(1)
const user = await post.user // SELECT * FROM users WHERE id = post.userId
// Eager load (single query)
const posts = await Post.includes('user').load()
posts[0].user // already resolved — no extra queryCustom FK or table
static author = belongsTo('users', { foreignKey: 'authorId' })
static creator = belongsTo('users', { foreignKey: 'creatorId' })touch: true
Updates the parent's updatedAt timestamp when the child is saved:
static order = belongsTo('orders', { touch: true })Polymorphic belongsTo
// schema.ts
export const comments = pgTable('comments', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
body: text('body').notNull(),
commentableId: integer('commentable_id').notNull(),
commentableType: text('commentable_type').notNull(), // 'Post' | 'Video'
})// models/Comment.model.ts
@model('comments')
export class Comment extends ApplicationRecord {
static commentable = belongsTo(undefined, { polymorphic: true })
}const comment = await Comment.find(1)
const target = await comment.commentable
// Returns a Post or Video instance based on commentable_typehasMany
The inverse side — no FK on this table.
// models/User.model.ts
import { hasMany } from 'active-drizzle'
@model('users')
export class User extends ApplicationRecord {
static posts = hasMany()
// Inferred: SELECT * FROM posts WHERE user_id = this.id
}const user = await User.find(1)
const posts = await user.posts.load() // Relation<Post>
// Filter / order the association
const recent = await user.posts
.where({ published: true })
.order('createdAt', 'desc')
.limit(5)
.load()Custom FK or table
static authoredPosts = hasMany('posts', { foreignKey: 'authorId' })dependent: 'destroy'
Destroy all children when the parent is destroyed:
static comments = hasMany({ dependent: 'destroy' })counterCache
Automatically maintain a counter column on the parent table:
// schema.ts — parent table must have the counter column
export const users = pgTable('users', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
postsCount: integer('posts_count').notNull().default(0),
})// models/User.model.ts
@model('users')
export class User extends ApplicationRecord {
static posts = hasMany({ counterCache: true })
// Increments/decrements user.postsCount automatically
}through (has-many-through)
// schema.ts
export const doctors = pgTable('doctors', { id: integer('id').primaryKey().generatedAlwaysAsIdentity() })
export const patients = pgTable('patients', { id: integer('id').primaryKey().generatedAlwaysAsIdentity() })
export const appointments = pgTable('appointments', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
doctorId: integer('doctor_id').notNull(),
patientId: integer('patient_id').notNull(),
})// models/Doctor.model.ts
@model('doctors')
export class Doctor extends ApplicationRecord {
static appointments = hasMany()
static patients = hasMany('patients', { through: 'appointments' })
}acceptsNestedAttributesFor
See the Nested Attributes page.
hasOne
Like hasMany but returns a single record (Promise, not Relation):
// schema.ts
export const profiles = pgTable('profiles', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull(),
bio: text('bio'),
})// models/User.model.ts
@model('users')
export class User extends ApplicationRecord {
static profile = hasOne()
}const user = await User.find(1)
const profile = await user.profile // Promise<Profile | null>habtm — has-and-belongs-to-many
Many-to-many through a pure join table (no model for the join):
// schema.ts
export const posts = pgTable('posts', { id: integer('id').primaryKey().generatedAlwaysAsIdentity() })
export const tags = pgTable('tags', { id: integer('id').primaryKey().generatedAlwaysAsIdentity(), name: text('name').notNull() })
export const posts_tags = pgTable('posts_tags', {
postId: integer('post_id').notNull(),
tagId: integer('tag_id').notNull(),
})// models/Post.model.ts
import { habtm } from 'active-drizzle'
@model('posts')
export class Post extends ApplicationRecord {
static tags = habtm('posts_tags')
// FK inferred: posts_tags.post_id → this.id → tags
}const post = await Post.find(1)
const tags = await post.tags.load() // Relation<Tag>Eager loading with includes
Load associations in a single query using Drizzle's relational API:
// One query: posts + user + comments
const posts = await Post
.includes('user', 'comments')
.where({ published: true })
.order('createdAt', 'desc')
.load()
posts[0].user // User instance — already loaded
posts[0].comments // Comment[] — already loadedDrizzle relations() required
For includes() and nested pluck() to work, you must define Drizzle relations() in your schema file alongside the table definitions. See Querying → Pluck for details.
Nested includes:
const orders = await Order.includes({ lineItems: { includes: 'product' } }).load()
orders[0].lineItems[0].product // Product instance