Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 010 / 100

Active Record associations: patterns, pitfalls and fixes

Relationships between models.

Expona Admin6 min read

Why Associations Matter

Your Rails models live in a database shaped by relationships. A product has many reviews, a user belongs to a team, a comment can live on either a post or a photo. Getting Active Record associations: patterns, pitfalls and fixes right is what separates code that scales from code that becomes a maintenance nightmare. When you nail your associations, queries stay clean, your team understands the domain instantly, and N+1 problems become obvious before they hit production.

Associations — Match the Database Shape

Rails gives us four core association types, and each one maps directly to how your foreign keys and join tables are structured in the database. Understanding the shape is the first step.

belongs_to: Many to One

A review belongs to a product. Many reviews point to one product via a foreign key.

ruby
class Review < ApplicationRecord
  belongs_to :product
end

class Product < ApplicationRecord
  has_many :reviews
end

This assumes your reviews table has a product_id column. When you call review.product, Rails loads that associated product by looking up the foreign key. The key insight: the model with the foreign key owns the belongs_to declaration.

has_many: One to Many

A product has many reviews. The product model declares this relationship, but the database truth lives in reviews.product_id.

ruby
class Product < ApplicationRecord
  has_many :reviews
end

Now product.reviews returns a collection. Rails builds a query like SELECT * FROM reviews WHERE product_id = ? under the hood. You can chain scopes: product.reviews.where(rating: 5).order(:created_at).

has_many :through: Many to Many

A user belongs to many teams, and a team has many users. The join table is your source of truth.

ruby
class User < ApplicationRecord
  has_many :memberships
  has_many :teams, through: :memberships
end

class Team < ApplicationRecord
  has_many :memberships
  has_many :users, through: :memberships
end

class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :team
end

This pattern shines when your join table carries extra data. A Membership might have a role column—is the user an admin or member? Now you can do user.memberships.where(role: 'admin').map(&:team) without extra queries.

has_one: One to One

A user has one profile. Different from has_many because there's a hard constraint: each user gets exactly one row in the profiles table.

ruby
class User < ApplicationRecord
  has_one :profile
end

class Profile < ApplicationRecord
  belongs_to :user
end

The profiles table still has a user_id foreign key, but Rails enforces the "one" part at the application layer. When you call user.profile, you get a single record, not a collection.

The dependent Option — What Happens on Delete

When you destroy a parent record, what happens to its children? This is where many teams find surprises in production.

ruby
class Product < ApplicationRecord
  has_many :reviews, dependent: :destroy
end

With dependent: :destroy, deleting a product triggers destruction of all its reviews. Rails loops through each review and calls destroy, firing callbacks and validations. This is safe but slow for large datasets.

ruby
class Product < ApplicationRecord
  has_many :reviews, dependent: :delete_all
end

With dependent: :delete_all, Rails issues a single SQL DELETE FROM reviews WHERE product_id = ?. Fast, but callbacks and validations don't run. If a review has a callback that updates a cache or sends a webhook, it won't fire.

Other options include dependent: :nullify (sets the foreign key to NULL, orphaning the child) and dependent: :restrict_with_exception (raises an error if you try to delete a parent with children). Choose based on your domain: does a deleted product orphan reviews, or do reviews cease to exist?

Without specifying dependent, Rails does nothing—your foreign keys sit there, creating orphans. In most cases, that's a bug waiting to happen.

inverse_of — Reusing Objects in Memory

Load a product and its reviews. Rails makes two queries: one for the product, one for the reviews. But then you loop through reviews and call review.product. Without inverse_of, Rails queries the database again for each review.

ruby
class Product < ApplicationRecord
  has_many :reviews, inverse_of: :product
end

class Review < ApplicationRecord
  belongs_to :product, inverse_of: :reviews
end

With inverse_of declared on both sides, Rails caches the relationship in memory. When you access review.product, it reuses the product object already loaded, avoiding extra queries.

ruby
product = Product.includes(:reviews).first
product.reviews.each do |review|
  # Without inverse_of: extra query for each review.product
  # With inverse_of: reuses the loaded product object
  puts review.product.name
end

This is especially powerful with includes and eager_load. Rails needs to know which direction the relationship points so it can wire up the association in memory. If your association names don't match the default convention—say, has_many :recent_reviews—you must explicitly pass inverse_of: :product to tell Rails which belongs_to it maps to.

Rails 6.1+ can infer inverse_of in many cases, but being explicit is safer and makes the code clearer.

Polymorphic Associations — One Model, Many Parents

A comment can belong to a post or a photo. Instead of separate comment tables for each, use a polymorphic association.

ruby
class Comment < ApplicationRecord
  belongs_to :commentable, polymorphic: true
end

class Post < ApplicationRecord
  has_many :comments, as: :commentable
end

class Photo < ApplicationRecord
  has_many :comments, as: :commentable
end

The comments table needs two columns: commentable_type (the class name) and commentable_id (the primary key). When you create a comment on a post, Rails stores commentable_type: 'Post' and commentable_id: 123.

ruby
post = Post.first
comment = post.comments.create(body: "Great post!")
comment.commentable # returns the post

photo = Photo.first
comment = photo.comments.create(body: "Love this!")
comment.commentable # returns the photo

Polymorphic associations reduce duplication but add complexity. Querying becomes trickier: Comment.where(commentable_type: 'Post') is less elegant than a direct foreign key. Use them when you genuinely have one table serving multiple parent types; don't use them as a shortcut to avoid proper modeling.

Common Mistakes

Forgetting dependent: :destroy

You delete a user, but their profile lingers in the database. Over time, you accumulate orphaned records that confuse reports and slow down cleanup scripts. Always decide explicitly what happens to children.

N+1 Queries with Associations

ruby
@products = Product.all
@products.each do |product|
  puts product.reviews.count  # query for each product
end

Fix it with includes:

ruby
@products = Product.includes(:reviews).all

Confusing has_many :through with has_and_belongs_to_many

has_and_belongs_to_many is a shorthand for a join table without a model. Use it only when the join table carries no extra data and you never need to query it directly. For anything with metadata—roles, timestamps, flags—build a proper join model and use has_many :through.

Polymorphic Associations Gone Wild

Using polymorphism to avoid real domain modeling creates brittle code. If you're polymorphic on ten different types, you've probably got a deeper design problem. Stick to one or two natural cases per model.

Missing Validation on Foreign Keys

ruby
class Review < ApplicationRecord
  belongs_to :product
  validates :product_id, presence: true
end

This validates that the foreign key is set. But Rails doesn't do this by default, so orphaned records can slip through.

Key Takeaways

  • Match your associations to your database schema: belongs_to where you have the foreign key, has_many on the other side, has_many :through for many-to-many with a proper join model.
  • Always specify dependent on has_many and has_one to prevent orphaned records and data surprises.
  • Use inverse_of on both sides of an association to cache relationships in memory and avoid redundant queries.
  • Polymorphic associations solve a real problem when one model naturally belongs to multiple parents; don't use them to avoid proper modeling.
  • Combine associations with includes and eager_load to keep your queries efficient and predictable.
  • Validate the presence of foreign keys in your models to catch data integrity issues early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles