Ruby on Rails Zero to Advanced #7: Associations, Validations & Callbacks
Real data is connected: users write articles, articles receive comments. Real data is also messy: empty titles, duplicate emails, typos. In this part you will connect models with associations, keep bad data out with validations, and run code at specific moments in a record's life with callbacks. We continue the blog app from part 6.
belongs_to and has_many
A one-to-many relationship needs a foreign key column on the "many" side. The references type creates it, with an index and a database-level foreign key:
bin/rails generate model User name:string email:string
bin/rails generate migration AddUserToArticles user:references
bin/rails generate model Comment article:references author_name:string body:text
bin/rails db:migrate
Output (migrate step, shortened):
== 20261013040000 CreateUsers: migrated (0.0011s) =============================
== 20261013040100 AddUserToArticles: migrating ================================
-- add_reference(:articles, :user, {null: false, foreign_key: true})
-> 0.0031s
== 20261013040100 AddUserToArticles: migrated (0.0032s) =======================
== 20261013040200 CreateComments: migrated (0.0018s) ==========================
Because the new user_id column is NOT NULL, start from an empty development database (bin/rails db:reset) or give existing rows a user first. Then declare both sides of each relationship:
# app/models/user.rb
class User < ApplicationRecord
has_many :articles, dependent: :destroy
has_many :comments, through: :articles
end
# app/models/article.rb
class Article < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
end
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :article
end
Each declaration generates methods. Try them in bin/rails console:
budi = User.create!(name: "Budi", email: "budi@example.com")
article = budi.articles.create!(title: "Associations 101")
article.user.name
article.comments.create!(author_name: "Siti", body: "Great post!")
budi.articles.count
budi.comments.pluck(:body)
Output (transaction lines omitted):
User Create (0.4ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES (?, ?, ?, ?) RETURNING "id" [["name", "Budi"], ["email", "budi@example.com"], ...]
=> #<User:0x000000012a1b2c30 id: 1, name: "Budi", email: "budi@example.com", ...>
Article Create (0.3ms) INSERT INTO "articles" (..., "user_id") VALUES (...) RETURNING "id" [["title", "Associations 101"], ..., ["user_id", 1]]
=> #<Article:0x000000012a3d4e58 id: 1, title: "Associations 101", user_id: 1, ...>
=> "Budi"
Comment Create (0.3ms) INSERT INTO "comments" ("article_id", "author_name", "body", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?) RETURNING "id" [["article_id", 1], ["author_name", "Siti"], ["body", "Great post!"], ...]
=> #<Comment:0x000000012a5f6a10 id: 1, article_id: 1, author_name: "Siti", body: "Great post!", ...>
Article Count (0.1ms) SELECT COUNT(*) FROM "articles" WHERE "articles"."user_id" = ? [["user_id", 1]]
=> 1
Comment Pluck (0.2ms) SELECT "comments"."body" FROM "comments" INNER JOIN "articles" ON "comments"."article_id" = "articles"."id" WHERE "articles"."user_id" = ? [["user_id", 1]]
=> ["Great post!"]
budi.articles.create! fills user_id for you. article.user did not log a query because the user object was already in memory. The has_many :through association joins two tables so you can jump from a user to the comments on their articles in one call.
dependent: :destroy
What happens to comments when an article is deleted? With dependent: :destroy, Rails destroys the children first, so you never end up with orphaned rows (or a foreign key error):
Article.find(1).destroy
Output:
Article Load (0.1ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
TRANSACTION (0.1ms) BEGIN immediate TRANSACTION
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."article_id" = ? [["article_id", 1]]
Comment Destroy (0.2ms) DELETE FROM "comments" WHERE "comments"."id" = ? [["id", 1]]
Article Destroy (0.2ms) DELETE FROM "articles" WHERE "articles"."id" = ? [["id", 1]]
TRANSACTION (0.2ms) COMMIT TRANSACTION
=> #<Article:0x000000012b7c8d20 id: 1, title: "Associations 101", ...>
For tables with thousands of children, dependent: :delete_all issues one DELETE without loading records (but skips the children's callbacks).
Validations
Validations run before a record is saved. If any fail, save returns false and the reasons are stored in errors.
class Article < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
validates :title, presence: true, length: { minimum: 5, maximum: 100 }
validate :body_without_spam
private
def body_without_spam
errors.add(:body, "contains blocked words") if body.to_s.match?(/free money/i)
end
end
class User < ApplicationRecord
has_many :articles, dependent: :destroy
has_many :comments, through: :articles
validates :name, presence: true
validates :email, presence: true,
uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
end
article = Article.new(title: "Hi", body: "Get FREE MONEY now")
article.valid?
article.errors.full_messages
article.save
User.new(name: "Budi 2", email: "BUDI@example.com").save!
Output:
=> #<Article:0x000000012c0e1f48 id: nil, title: "Hi", body: "Get FREE MONEY now", ...>
=> false
=>
["User must exist",
"Title is too short (minimum is 5 characters)",
"Body contains blocked words"]
=> false
User Exists? (0.2ms) SELECT 1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER(?) LIMIT ? [["email", "BUDI@example.com"], ["LIMIT", 1]]
(blog):5:in '<main>': Validation failed: Email has already been taken (ActiveRecord::RecordInvalid)
Notice belongs_to adds a presence validation for the parent automatically ("User must exist"). Use optional: true when the parent really is optional.
A uniqueness validation alone is not enough: two requests at the same moment can both pass the check. Back it up with a unique index in a migration: add_index :users, :email, unique: true.
Callbacks
Callbacks are methods Rails calls at points in the lifecycle. Here is the order for creating a record:
class Comment < ApplicationRecord
belongs_to :article
before_validation { puts "1. before_validation" }
after_validation { puts "2. after_validation" }
before_save { puts "3. before_save" }
before_create { puts "4. before_create" }
after_create { puts "5. after_create" }
after_save { puts "6. after_save" }
after_commit { puts "7. after_commit" }
end
Article.first.comments.create!(author_name: "Andi", body: "Nice")
Output (SQL omitted):
1. before_validation
2. after_validation
3. before_save
4. before_create
5. after_create
6. after_save
7. after_commit
=> #<Comment:0x000000012c9d3a70 id: 2, article_id: 2, author_name: "Andi", body: "Nice", ...>
A typical, useful callback cleans up data before validation. Since Rails 7.1 there is an even simpler tool for that, normalizes:
class User < ApplicationRecord
normalizes :email, with: ->(email) { email.strip.downcase }
after_create_commit :log_signup
private
def log_signup
Rails.logger.info("New user signed up: #{id}")
end
end
user = User.create!(name: "Siti", email: " Siti@Example.COM ")
user.email
Output:
=> #<User:0x000000012d2e4b98 id: 2, name: "Siti", email: "siti@example.com", ...>
=> "siti@example.com"
Use after_commit (or after_create_commit) for side effects such as sending emails or enqueuing jobs, so they only happen after the data is really saved.
Common mistakes
- Plural vs singular.
belongs_to :user(singular),has_many :articles(plural). Getting this wrong raisesNameError: uninitialized constant. - Forgetting dependent. Without
dependent:, deleting a parent fails on the foreign key or leaves orphans. - Callback spaghetti. Callbacks that send emails, call APIs and update other models make code hard to follow and test. Keep callbacks for data concerns; put workflows in a plain service object or a job.
- Assigning without self in callbacks.
email = email.downcasecreates a local variable. Useself.email = .... - Trusting only model validations. Add
null: false, foreign keys and unique indexes at the database level too.
Exercise
Add a Category model (name, unique and required) where a category has_many :articles and an article belongs_to :category, optional: true. Then check:
tech = Category.create!(name: "Tech")
Category.new(name: "Tech").valid?
Article.first.update!(category: tech)
tech.articles.count
Output (SQL omitted):
=> #<Category:0x000000012e1f3c88 id: 1, name: "Tech", ...>
=> false
=> true
=> 1
Summary
belongs_to, has_many and has_many :through turn foreign keys into convenient methods; dependent: keeps related data consistent; validations stop invalid records and explain why in errors; and callbacks hook into the lifecycle, best used sparingly. In part 8 we put this to work in the browser with forms, strong parameters and a complete CRUD interface.
Related articles
PostgreSQL Zero to Advanced #5: Aggregation, GROUP BY & HAVING
Turn raw orders into business numbers: total revenue, average order value, sales per month and top customers using COUNT, SUM, AVG, GROUP BY, HAVING and FILTER.
PHP Zero to Advanced #5: Arrays & Array Functions
Master PHP arrays: indexed and associative arrays, nested data, destructuring, and the essential functions array_map, array_filter, array_reduce, usort, and friends.
Python Zero to Advanced #5: Lists, Tuples, Dictionaries, Sets & Comprehensions
Master Python's built-in collections: lists, tuples, dictionaries, and sets, when to use each, slicing, unpacking, and writing concise list, dict, and set comprehensions.