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.
class Review < ApplicationRecord
belongs_to :product
end
class Product < ApplicationRecord
has_many :reviews
endThis 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.
class Product < ApplicationRecord
has_many :reviews
endNow 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.
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
endThis 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.
class User < ApplicationRecord
has_one :profile
end
class Profile < ApplicationRecord
belongs_to :user
endThe 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.
class Product < ApplicationRecord
has_many :reviews, dependent: :destroy
endWith 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.
class Product < ApplicationRecord
has_many :reviews, dependent: :delete_all
endWith 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.
class Product < ApplicationRecord
has_many :reviews, inverse_of: :product
end
class Review < ApplicationRecord
belongs_to :product, inverse_of: :reviews
endWith 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.
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
endThis 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.
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
endThe 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.
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 photoPolymorphic 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
@products = Product.all
@products.each do |product|
puts product.reviews.count # query for each product
endFix it with includes:
@products = Product.includes(:reviews).allConfusing 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
class Review < ApplicationRecord
belongs_to :product
validates :product_id, presence: true
endThis 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_towhere you have the foreign key,has_manyon the other side,has_many :throughfor many-to-many with a proper join model. - Always specify
dependenton has_many and has_one to prevent orphaned records and data surprises. - Use
inverse_ofon 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
includesandeager_loadto keep your queries efficient and predictable. - Validate the presence of foreign keys in your models to catch data integrity issues early.






Comments
Loading comments…