ActiveRecord Validations — Custom Validators and the Patterns Worth Knowing
Rails ships with a solid set of built-in validations that cover the common cases. Most applications, though, quickly accumulate requirements that built-ins can’t express cleanly — business rules about date ranges, format constraints specific to your domain, cross-field dependencies. That’s where custom validators come in. Understanding when to write a validation method, when to extract a validator class, and what to avoid keeps your models readable and your validation logic testable.
Built-in Validations — The Foundation
Example:
class User < ApplicationRecord
validates :name, presence: true, length: { maximum: 100 }
validates :email, presence: true, uniqueness: { case_sensitive: false },
format: { with: /\A[^@\s]+@[^@\s]+\z/ }
validates :age, numericality: { greater_than: 0, less_than: 150 }, allow_nil: true
validates :status, inclusion: { in: %w[active inactive suspended] }
validates :bio, length: { maximum: 500 }, allow_blank: true
end
Key options that change validation behavior:
Example:
# :on restricts when validation runs
validates :password, presence: true, on: :create
validates :current_password, presence: true, on: :update_password # custom context
# :if and :unless for conditional validation
validates :company_name, presence: true, if: :business_account?
validates :trial_ends_at, presence: true, unless: -> { plan == "free" }
# Running with a custom context
user.valid?(:update_password) # triggers only :on => :update_password validations
user.save(context: :publish)
validate vs. validates
validates applies a pre-built validator by keyword name. validate registers a method on the model that Rails calls during the validation cycle.
Example:
class Event < ApplicationRecord
validates :title, presence: true # built-in via validates
validate :end_date_after_start_date # custom method via validate
validate :venue_available_on_event_date # another custom method
private
def end_date_after_start_date
return unless start_date && end_date
errors.add(:end_date, "must be after the start date") if end_date <= start_date
end
def venue_available_on_event_date
return unless venue && start_date
if Event.where(venue: venue, start_date: start_date).where.not(id: id).exists?
errors.add(:venue, "is already booked for this date")
end
end
end
errors.add(:field, "message") adds the error to a specific attribute. errors.add(:base, "message") adds a record-level error not tied to any attribute — use this for cross-field constraints where no single field is the “wrong” one.
Custom Validator Classes — Reusable Across Models
When the same validation logic applies to multiple models or attributes, extract it into a validator class.
Example:
# app/validators/phone_number_validator.rb
class PhoneNumberValidator < ActiveModel::EachValidator
PATTERN = /\A\+?[\d\s\-().]{7,20}\z/
def validate_each(record, attribute, value)
return if value.blank? && options[:allow_blank]
unless value.to_s.match?(PATTERN)
record.errors.add(attribute, options[:message] || "is not a valid phone number")
end
end
end
Example:
# Using the custom validator — the key name matches the class name minus "Validator"
class User < ApplicationRecord
validates :mobile, phone_number: true
validates :work_phone, phone_number: true, allow_blank: true
end
class Contact < ApplicationRecord
validates :phone, phone_number: { message: "must include country code" }
end
The options hash in validate_each gives access to any options passed in the validates call. This lets the validator be configurable without subclassing.
validates_with — Multi-Attribute and Record-Level Validators
validates_with passes the entire record to a validator class, useful when the validation involves multiple attributes together.
Example:
class DateRangeValidator < ActiveModel::Validator
def validate(record)
start_field = options.fetch(:start, :start_date)
end_field = options.fetch(:end, :end_date)
start_val = record.public_send(start_field)
end_val = record.public_send(end_field)
return unless start_val && end_val
if end_val <= start_val
record.errors.add(end_field, "must be after #{start_field.to_s.humanize}")
end
end
end
Example:
class Event < ApplicationRecord
validates_with DateRangeValidator
validates_with DateRangeValidator, start: :registration_opens, end: :registration_closes
end
class Booking < ApplicationRecord
validates_with DateRangeValidator, start: :check_in, end: :check_out
end
Skipping Validations — When and When Not To
Several ActiveRecord methods bypass validations entirely:
Example:
# These skip validations — use with caution
record.save(validate: false)
record.update_column(:status, "inactive") # single column, no validations, no callbacks
record.update_columns(status: "inactive") # multiple columns, no validations, no callbacks
Record.insert_all([{ name: "Alice" }]) # bulk insert, no validations
Record.upsert_all([{ id: 1, name: "Bob" }]) # bulk upsert, no validations
When to use them:
- Data migrations where the data is guaranteed correct by the migration context and validations would be too slow on millions of rows
- Console fixes for specific records when you understand exactly what you’re doing
- Background jobs resetting a denormalized counter that can’t fail validation by design
When not to use them:
- Application code paths where user data flows through
- Anywhere you’re not certain the data satisfies the invariants validations enforce
A model validation that can be skipped in production application code is a validation that can’t be trusted.
Testing Validations
Example:
# With shoulda-matchers — fast, declarative
RSpec.describe User, type: :model do
it { is_expected.to validate_presence_of(:email) }
it { is_expected.to validate_uniqueness_of(:email).case_insensitive }
it { is_expected.to validate_length_of(:name).is_at_most(100) }
it { is_expected.to validate_inclusion_of(:status).in_array(%w[active inactive]) }
end
Example:
# Testing custom validators directly — explicit and readable
RSpec.describe Event, type: :model do
describe "end_date_after_start_date" do
let(:event) { build(:event, start_date: Date.today, end_date: Date.yesterday) }
it "adds an error when end_date precedes start_date" do
expect(event).not_to be_valid
expect(event.errors[:end_date]).to include("must be after the start date")
end
end
end
# Testing the validator class in isolation
RSpec.describe PhoneNumberValidator do
let(:record) { double("record", errors: ActiveModel::Errors.new(double)) }
it "rejects obviously invalid values" do
validator = described_class.new(attributes: [:phone])
validator.validate_each(record, :phone, "not-a-phone")
expect(record.errors[:phone]).not_to be_empty
end
end
Pro-Tip: Use
validates_withto share validator logic across attributes and models rather than duplicating the samevalidate :methodpattern in multiple places. APhoneNumberValidatorthat handles both:mobileand:work_phoneonUserand:phoneonContacthas one place to fix when the format requirement changes — and one set of tests to update when it does.
Conclusion
ActiveRecord validations do their best work when they live close to the data they protect and are testable in isolation. Built-in validators cover the mechanical constraints; custom validation methods handle business rules specific to one model; validator classes handle logic that applies across models or attributes. The discipline that matters most is treating validation-skipping methods with the same caution you’d give direct SQL — appropriate for migration contexts, dangerous in application logic that handles user input.
FAQs
Q1: Where should complex validation logic live — the model or a service object?
Validation logic that enforces data integrity belongs on the model. Logic that enforces business process rules — “a user can only have three active subscriptions” — is sometimes better in a service object that returns errors explicitly rather than polluting model validations with process state. The distinction: invariants that should always be true at the data level belong in validations; rules that apply only in certain application flows belong in service objects.
Q2: How do I run validations without saving?
Call record.valid? — it runs all validations and populates record.errors without persisting. record.valid?(:context) runs validations for a specific context. This is useful in forms, APIs, and services where you want to check validity before deciding whether to proceed.
Q3: Can I validate associated records?
Yes: validates_associated :address runs validations on the associated record when the parent is validated. The parent is invalid if any association is invalid. Note that validates_associated doesn’t add the association if it doesn’t exist — use validates :address, presence: true alongside it if the association is required.
Q4: How do I add a validation error in a before_save callback?
Add to errors and throw :abort to halt the save: errors.add(:base, "message"); throw :abort. Using before_validation instead of before_save is cleaner for most cases — the error is added in the normal validation cycle and valid? reflects it before a save attempt.
Q5: Is uniqueness validation safe for concurrent requests?
No — validates :email, uniqueness: true has a race condition. Two concurrent requests can both pass the uniqueness check before either commits. The database-level solution is a unique index: add_index :users, :email, unique: true. Let the database constraint be the guarantee; catch ActiveRecord::RecordNotUnique at the controller level and add a user-facing error message.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀