Validations keep bad data out, and misused callbacks are one of the most common sources of Rails bugs.
Validations run when you call save, create, update or valid?. If any fails, nothing is written and errors is populated:
validates :title, presence: true, length: { maximum: 120 }
validates :slug, uniqueness: { case_sensitive: false }, format: { with: /\A[a-z0-9-]+\z/ }
validates :price, numericality: { greater_than_or_equal_to: 0 }Other built-ins: inclusion: { in: %w[draft published] }, comparison, acceptance, confirmation. Add allow_nil: true, if:/unless: and on: :create to scope them. For custom logic, write validate :method_name and call errors.add(:field, "message").
uniqueness checks with a SELECT before inserting, so two concurrent requests can both pass. Always back it with a unique index in the database.
Callbacks hook into the lifecycle: before_validation, before_save, after_create, before_destroy, and the transaction-aware after_commit (with shortcuts after_create_commit and after_update_commit). Good uses are small, model-local concerns: normalising data before validation (Rails 7.1+ also has normalizes :email, with: -> { it.strip.downcase }) or setting derived fields.
Rules for callbacks:
- Use
after_commit(notafter_save) for side effects like enqueueing jobs or sending email, so they don't fire for rolled-back transactions. - Don't put cross-model workflows in callbacks; an explicit method or service object is easier to follow and test.
- Methods like
update_column,update_allandinsert_allskip validations and callbacks.
Example
class Article < ApplicationRecord
normalizes :slug, with: -> { it.strip.downcase }
validates :title, presence: true, length: { maximum: 120 }
validates :slug, presence: true, uniqueness: true,
format: { with: /\A[a-z0-9-]+\z/, message: "only a-z, 0-9 and -" }
validate :publish_date_not_in_past, if: :published?
before_validation :generate_slug, on: :create
after_create_commit -> { NotifyFollowersJob.perform_later(self) }
private
def generate_slug = self.slug ||= title.to_s.parameterize
def publish_date_not_in_past
errors.add(:published_at, "can't be in the past") if published_at&.past?
end
end
a = Article.new(title: "", slug: "Bad Slug!")
a.valid? # => false
a.errors.full_messages
# => ["Title can't be blank", "Slug only a-z, 0-9 and -"]
Article.create!(title: "Hello World") # slug "hello-world"; job enqueued after commitRemember
- save and valid? run validations; errors explains failures
- Back uniqueness validations with a unique database index
- Use after_commit for side effects; keep callbacks small and local
Watch out for
- Sending emails or enqueueing jobs in after_save, which fires even if the transaction later rolls back.