active-record-validations
Active Record Validations Expert
Write correct, layered validations for Rails 8.1 applications. Pair every model validation with appropriate database constraints. Never rely on model validations alone for data integrity.
Philosophy
- Validations are UX, constraints are safety — Model validations produce friendly error messages. DB constraints prevent corrupt data. You need both.
- Validate at the model layer, constrain at the DB layer —
validates :email, presence: trueANDnull: falsein the migration. Always. - Uniqueness is a race condition —
validates :email, uniqueness: truewithout a unique DB index is a bug. Full stop. - Normalize before you validate — Use
normalizes(Rails 7.1+) to strip/downcase BEFORE validation runs. Don't validate messy input. - Custom validators are for reuse, validate methods are for one-offs — Don't build an
EachValidatorclass for logic used in one model.
Critical Rules — Read These First
Pair validations with DB constraints
# Model
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true
end
# Migration — without this, anything that bypasses Active Record (bulk imports, raw SQL, other apps) can insert invalid data
class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :email, null: false # backs presence
t.timestamps
end
add_index :users, :email, unique: true # backs uniqueness
end
end
Pairing cheat sheet:
| Validation | DB Constraint |
|---|---|
presence: true |
null: false |
uniqueness: true |
add_index unique: true |
uniqueness: { scope: :tenant_id } |
add_index [:tenant_id, :email], unique: true |
numericality: { greater_than: 0 } |
CHECK constraint (optional but ideal) |
inclusion: { in: %w[a b c] } |
CHECK constraint or enum type |
length: { maximum: 255 } |
limit: 255 on column |
Uniqueness validation alone is a race condition
# Two concurrent requests can both pass validation and insert duplicates:
validates :slug, uniqueness: true
# Without: add_index :posts, :slug, unique: true
# Handle the DB constraint error in your controller:
def create
@user = User.new(user_params)
@user.save!
rescue ActiveRecord::RecordNotUnique
@user.errors.add(:email, :taken)
render :new, status: :unprocessable_entity
end
Normalize before validating
class User < ApplicationRecord
# Rails 7.1+ normalizations — runs before validation
normalizes :email, with: ->(email) { email.strip.downcase }
normalizes :name, with: ->(name) { name.strip }
validates :email, presence: true, uniqueness: true,
format: { with: URI::MailTo::EMAIL_REGEXP }
end
Without normalization, " Alice@Example.COM " fails uniqueness checks against "alice@example.com" — or worse, creates a duplicate.
Boolean presence is special
# WRONG — false.blank? is true, so this rejects false!
validates :active, presence: true
# RIGHT — validates the value is actually true or false
validates :active, inclusion: { in: [true, false] }
# Also acceptable
validates :active, exclusion: { in: [nil] }
Instructions
Step 1: Choose the Right Validation
Before writing a validation, ask: "Where does this rule belong?"
| Rule Type | Where | Example |
|---|---|---|
| Data exists | Model + DB null: false |
validates :name, presence: true |
| Data is unique | Model + DB unique index | validates :email, uniqueness: true |
| Data format | Model only (DB can't regex efficiently) | validates :email, format: { with: /.../ } |
| Business logic | Model validate method |
validate :end_after_start |
| Referential integrity | DB foreign key | add_foreign_key in migration |
| Cross-record consistency | DB constraint or service object | CHECK constraint or transaction |
Step 2: Use Built-in Validators Correctly
presence
validates :title, presence: true
# Checks: !value.blank? (rejects nil, "", " ")
# Pair with: null: false in migration
For associations, validate the association, not the foreign key:
# WRONG — only checks the integer column isn't nil
validates :author_id, presence: true
# RIGHT — also verifies the Author record exists
belongs_to :author # Rails 5+ validates presence by default
belongs_to validates presence by default since Rails 5. To make it optional: belongs_to :author, optional: true.
uniqueness
validates :email, uniqueness: true
validates :name, uniqueness: { scope: :account_id } # unique per account
validates :slug, uniqueness: { case_sensitive: false }
validates :email, uniqueness: { conditions: -> { where(deleted_at: nil) } }
Always add the matching unique index in a migration.
format
# Use \A and \z for string boundaries, not ^ and $
validates :username, format: { with: /\A[a-z0-9_]+\z/ }
# ^ and $ match LINE boundaries in Ruby, not string boundaries.
# "valid\nevil" passes /^[a-z]+$/ — this is a security hole that enables injection attacks.
numericality
validates :price, numericality: { greater_than: 0 }
validates :quantity, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :discount, numericality: { in: 0..100 }
Gotcha: numericality rejects nil by default. If the field is optional, add allow_nil: true.
Gotcha: Float precision — numericality converts to Float then BigDecimal. For money, use decimal columns with explicit precision/scale and validate as integer cents, or use a money gem.
length
validates :name, length: { minimum: 2, maximum: 100 }
validates :bio, length: { maximum: 500 }
validates :pin, length: { is: 4 }
validates :password, length: { in: 8..128 }
inclusion / exclusion
validates :role, inclusion: { in: %w[admin editor viewer] }
validates :subdomain, exclusion: { in: %w[www admin api] }
# Dynamic:
validates :size, inclusion: { in: ->(record) { record.available_sizes } }
comparison
validates :end_date, comparison: { greater_than: :start_date }
validates :retry_count, comparison: { less_than_or_equal_to: 10 }
confirmation
validates :email, confirmation: true
validates :email_confirmation, presence: true # confirmation field itself must be present
# Only validate confirmation when email changes:
validates :email, confirmation: true
validates :email_confirmation, presence: true, if: :email_changed?
acceptance
validates :terms, acceptance: true # virtual attribute, no DB column needed
validates :eula, acceptance: { accept: ["TRUE", "accepted"] }
absence
validates :supplementary_address, absence: true, unless: :has_primary_address?
validates_associated
has_many :line_items
validates_associated :line_items # calls valid? on each line_item
# Don't put validates_associated on BOTH sides of an association — it creates
# an infinite loop (parent validates child, child validates parent, etc.).
# Parent validates children; children rely on belongs_to for the reverse.
Step 3: Conditional Validations
Use :if / :unless to scope when validations run.
# Symbol — cleanest for named methods
validates :card_number, presence: true, if: :paid_with_card?
# Lambda — fine for one-liners
validates :company_name, presence: true, if: -> { account_type == "business" }
# Group related conditionals with with_options
with_options if: :is_admin? do
validates :password, length: { minimum: 10 }
validates :email, presence: true
end
# Combine conditions (ALL :if must be true, NO :unless can be true)
validates :parking_spot, presence: true,
if: [:has_car?, :works_onsite?],
unless: -> { remote_employee? }
Prefer named methods over complex lambdas — a method name communicates intent (paid_with_card?) better than inline logic, and it's easier to test independently.
on: context
validates :email, uniqueness: true, on: :create # skip on update
validates :age, numericality: true, on: :update
# Custom contexts for multi-step forms:
validates :address, presence: true, on: :checkout
# Trigger: user.valid?(:checkout) or user.save(context: :checkout)
Step 4: Custom Validations
validate method (one-off, single model)
class Event < ApplicationRecord
validate :end_after_start
validate :not_in_past, on: :create
private
def end_after_start
return if end_date.blank? || start_date.blank?
if end_date <= start_date
errors.add(:end_date, "must be after start date")
end
end
def not_in_past
return if start_date.blank?
if start_date < Date.current
errors.add(:start_date, "can't be in the past")
end
end
end
Guard against nil with return if field.blank? — custom validations receive nil when the field is empty, which causes NoMethodError. Let presence handle the "is it present?" check separately.
EachValidator (reusable across models)
# app/validators/email_validator.rb
class EmailValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless URI::MailTo::EMAIL_REGEXP.match?(value)
record.errors.add(attribute, options[:message] || "is not a valid email")
end
end
end
# Usage — name becomes the validation key (email:)
class User < ApplicationRecord
validates :email, email: true
validates :backup_email, email: true, allow_blank: true
end
Naming convention: XxxValidator → validates :attr, xxx: true. The class name minus Validator, lowercased/underscored.
Validator class (validates_with, complex cross-field)
# app/validators/address_validator.rb
class AddressValidator < ActiveModel::Validator
def validate(record)
%i[street city zip].each do |field|
if record.send(field).blank?
record.errors.add(field, "is required for a complete address")
end
end
end
end
class Order < ApplicationRecord
validates_with AddressValidator, if: :shipping_required?
end
Note: validates_with validators are initialized once for the app lifecycle. Storing instance state causes data to leak between validations of different records.
Step 5: Error Handling
Reading errors
record.valid? # triggers validations, returns bool
record.errors.full_messages # ["Name can't be blank", ...]
record.errors[:name] # ["can't be blank", "is too short..."]
record.errors.where(:name, :too_short) # [ActiveModel::Error objects]
record.errors.added?(:name, :blank) # true/false — check specific error type
record.errors.of_kind?(:name, :blank) # same as added? but doesn't check options
Adding errors manually
errors.add(:email, :invalid) # uses i18n default
errors.add(:email, :taken, value: email) # interpolates %{value}
errors.add(:base, "Something is wrong with this record") # record-level error
Custom error types for programmatic handling
errors.add(:discount, :exceeds_maximum, count: 50)
# In en.yml:
# activerecord.errors.models.order.attributes.discount.exceeds_maximum: "cannot exceed %{count}%"
Displaying in views
<% if @record.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@record.errors.count, "error") %> prohibited saving:</h2>
<ul>
<% @record.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
Step 6: Strict Validations
Use for programmer errors, not user input errors.
validates :token, presence: { strict: true }
# Raises ActiveModel::StrictValidationFailed instead of adding to errors
validates :token, presence: { strict: TokenMissingError }
# Raises your custom exception
Use strict validations for: internal invariants, system-generated fields, things that should never fail if code is correct. Don't use for: user-facing form fields.
Step 7: Normalizations (Rails 7.1+)
class User < ApplicationRecord
normalizes :email, with: ->(email) { email.strip.downcase }
normalizes :phone, with: ->(phone) { phone.gsub(/\D/, "") }
normalizes :name, with: ->(name) { name.squish } # collapse whitespace
# apply: false — skip normalization on nil (default normalizes nil too)
normalizes :nickname, with: ->(n) { n.strip }, apply_to_nil: false
end
# Normalizations run:
# - On assignment: user.email = " FOO@BAR.COM " → "foo@bar.com"
# - Before validation
# - On finder methods: User.find_by(email: " FOO@BAR.COM ") normalizes the query value
Normalize before you validate. Without this, " Alice@Example.COM " and "alice@example.com" are treated as different values — leading to duplicates, failed lookups, and confusing error messages.
Methods That Skip Validations
These methods write to DB without running validations. Know them:
update_all, update_column, update_columns, insert, insert!, insert_all, insert_all!, upsert, upsert_all, touch, touch_all, toggle!, increment!, decrement!, update_attribute, save(validate: false)
If you use these, your DB constraints are your only safety net. This is why DB constraints matter.
Common Mistakes Agents Make
-
Adding
validates :foo, presence: truewithoutnull: falsein migration — Data will be inconsistent if anything bypasses Active Record. -
Using
validates :email, uniqueness: truewithout a unique index — Race condition. Two concurrent requests can both pass validation and insert duplicates. -
Validating boolean presence —
validates :active, presence: truerejectsfalse. Useinclusion: { in: [true, false] }. -
Using
^and$in format regexes — Use\Aand\z. Line anchors allow injection via newlines. -
Not guarding nil in custom validate methods — If
presenceis optional, your custom method getsniland raisesNoMethodError. -
Putting validates_associated on both sides — Infinite loop. Only parent validates children.
-
Forgetting that numericality rejects nil — Add
allow_nil: trueif the field is optional. -
Not normalizing before uniqueness checks —
"foo@bar.com"and"Foo@Bar.com"are different without normalization/case_sensitive: false. -
Validating foreign key column instead of association —
validates :author_id, presence: truedoesn't verify the author exists.belongs_to :authordoes. -
Writing EachValidator for single-model logic — Overkill. Use a
validatemethod unless you need it in 2+ models.
Quick Reference
Validation triggers (run validations)
create, create!, save, save!, update, update!, valid?, invalid?
Options available on all validators
| Option | Purpose |
|---|---|
:message |
Custom error message (String or Proc) |
:on |
Context — :create, :update, or custom symbol |
:if / :unless |
Conditional — symbol, proc, or array |
:allow_nil |
Skip if value is nil |
:allow_blank |
Skip if value is blank? |
:strict |
Raise exception instead of adding error |
Message interpolation
validates :name, length: { minimum: 3, message: "must be at least %{count} characters" }
# Available: %{value}, %{attribute}, %{model}, %{count}
validates :email, uniqueness: {
message: ->(object, data) { "#{data[:value]} is already taken" }
}
See the references/ directory for complete examples and edge cases:
references/built-in-validators.md— All built-in validators with options, edge cases, and normalizationsreferences/custom-validators.md— EachValidator, Validator classes, PORO validatorsreferences/errors-api.md— Errors API (reading, adding, filtering) and I18n configurationreferences/conditional-validations.md— if/unless, with_options, :on context, multi-step formsreferences/db-constraints.md— DB constraint pairing, uniqueness race conditions, numericality edge casesreferences/testing.md— Testing patterns, performance considerations, miscellaneous recipes
More from thinkoodle/rails-skills
security
Expert guidance for writing secure Rails applications. Use when dealing with security, CSRF protection, XSS prevention, SQL injection, authentication, authorization, sanitize, html_safe, credentials, secrets, content security policy, session security, mass assignment, strong parameters, secure headers, file uploads, open redirects, or vulnerability remediation. Covers every major attack vector and the Rails-idiomatic defenses.
4stimulus
Expert guidance for building Stimulus controllers in Rails applications. Use when creating JavaScript behaviors, writing data-controller/data-action/data-target attributes, building interactive UI components, or working with Hotwire Stimulus. Covers controller creation, targets, values, actions, classes, outlets, lifecycle callbacks, progressive enhancement, and common patterns like clipboard, flash, modal, toggle, and form validation.
4i18n
Expert guidance for Rails I18n (internationalization and localization). Use when working with translations, locale files, t() / l() helpers, lazy lookups, pluralization, interpolation, date/time/number formatting, model translations, error message translations, setting locale from URL/header/session, or organizing YAML translation files. Triggers on "i18n", "internationalization", "translation", "locale", "localize", "t()", "translate", "multilingual", "pluralization", "locale file", "YAML translation".
4routing
Expert guidance for defining routes in Rails applications. Use when adding routes, working with resources, nested routes, namespaces, path helpers, routes.rb, RESTful design, API routes, URL helpers, or any routing-related task. Covers resources, singular resources, nesting, namespace vs scope, constraints, concerns, member/collection routes, and route testing.
4css-architecture
Expert guidance for CSS architecture in Rails applications using modern CSS (no Tailwind). Use when writing stylesheets, organizing CSS files, implementing dark mode, defining design tokens, using CSS custom properties, creating component styles, working with CSS layers (@layer), using the light-dark() function, setting up color schemes, or structuring CSS for maintainability. Covers design tokens, semantic color systems, component-scoped styles, utility classes, responsive patterns, and file organization.
4migrations
Expert guidance for writing safe, reversible Active Record migrations in Rails applications. Use when creating a migration, adding a column, removing a column, changing schema, modifying a table, creating a table, adding an index, adding a foreign key, renaming a column, changing column type, database migration, schema change, rolling back, migration error, data migration, multi-database migration, or any database structure change.
4