Ruby on Rails Validations — Presence Uniqueness Custom and Callbacks Explained
In this tutorial, you will learn about Ruby on Rails Validations. We cover key concepts, practical examples, and best practices to help you master this topic.
Ruby on Rails Validations ensure data integrity by checking model attributes before database save, with built-in helpers for presence, uniqueness, length, format, numericality, and custom validation methods.
What You'll Learn
- Using built-in validation helpers
- Writing custom validations
- Displaying validation errors in views
- Conditional and scoped validations
Why It Matters
Validations prevent bad data. Doda Browser uses validations for bookmark URLs, settings values, and user input. Durga Antivirus Pro uses validations for license keys, scan configurations, and threat signatures.
Real-World Use
User registration validates email format and password strength. Checkout validates credit card numbers. APIs validate request payloads. Validations are the first line of defense against data corruption.
flowchart LR
A["Validations"] --> B["Built-in"]
B --> C["Custom"]
C --> D["Errors"]
D --> E["Conditional"]
A:::current --> B
style A fill:#2563eb,stroke:#2563eb,color:#fff
style B fill:#dbeafe,stroke:#2563eb,color:#1e40af
style C fill:#dbeafe,stroke:#2563eb,color:#1e40af
style D fill:#dbeafe,stroke:#2563eb,color:#1e40af
style E fill:#f1f5f9,stroke:#94a3b8,color:#64748b
Basic Validations
class Article < ApplicationRecord
validates :title, presence: true
validates :body, presence: true, length: { minimum: 10 }
validates :email, uniqueness: true
validates :age, numericality: { greater_than: 0 }
end
Built-in Validation Helpers
presence and absence
validates :name, presence: true
validates :email, presence: { message: "is required" }
validates :legacy_field, absence: true # Must be blank
uniqueness
validates :email, uniqueness: true
validates :slug, uniqueness: { scope: :user_id, message: "already taken" }
validates :code, uniqueness: { case_sensitive: false }
length
validates :name, length: { minimum: 2 }
validates :bio, length: { maximum: 500 }
validates :password, length: { in: 8..72 }
validates :code, length: { is: 6 }
validates :title, length: { minimum: 5, too_short: "must be at least %{count} characters" }
numericality
validates :age, numericality: true
validates :price, numericality: { greater_than: 0 }
validates :quantity, numericality: { only_integer: true }
validates :discount, numericality: { greater_than_or_equal_to: 0, less_than: 1 }
validates :rating, numericality: { in: 1..5 }
validates :score, numericality: { other_than: 0 }
format
validates :email, format: { with: /\A[^@\s]+@[^@\s]+\z/,
message: "must be a valid email address" }
validates :zip_code, format: { with: /\A\d{5}(-\d{4})?\z/ }
inclusion and exclusion
validates :role, inclusion: { in: %w[admin user guest] }
validates :age, inclusion: { in: 0..150 }
validates :level, exclusion: { in: %w[admin superadmin] }
acceptance
validates :terms_of_service, acceptance: true
validates :privacy_policy, acceptance: { accept: ["yes", "true", "1"] }
confirmation
validates :password, confirmation: true
validates :email, confirmation: true
# Requires password and password_confirmation fields
Custom Validations
Custom Method
class Article < ApplicationRecord
validate :title_must_be_meaningful
private
def title_must_be_meaningful
forbidden_words = ["untitled", "draft", "new post"]
if title.present? && forbidden_words.include?(title.downcase)
errors.add(:title, "must be more descriptive")
end
end
end
Custom Validator Class
# app/validators/email_validator.rb
class EmailValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value =~ /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
record.errors.add(attribute, (options[:message] || "is not a valid email"))
end
end
end
# Usage
class User < ApplicationRecord
validates :email, email: true
validates :backup_email, email: { message: "is invalid" }
end
Conditional Validations
class User < ApplicationRecord
# With symbol
validates :password, presence: true, if: :password_required?
# With proc
validates :terms, acceptance: true, unless: -> { minor? }
# Multiple conditions
with_options if: :admin? do |admin|
admin.validates :role, inclusion: { in: %w[admin superadmin] }
admin.validates :permissions, presence: true
end
private
def password_required?
new_record? || password.present?
end
end
Validation Errors
user = User.new(name: "", email: "invalid")
user.valid? # false
puts user.errors.inspect
# #<ActiveModel::Errors:...>
user.errors.full_messages
# ["Name can't be blank", "Email is invalid"]
user.errors.where(:name)
user.errors.count # 2
user.errors.any? # true
# Add custom errors
user.errors.add(:base, "Account is suspended")
user.errors.add(:email, :taken, message: "already registered")
Errors in Views
<%= form_with model: @user do |form| %>
<% if @user.errors.any? %>
<div class="error-messages">
<h2><%= pluralize(@user.errors.count, "error") %> prevented saving:</h2>
<ul>
<% @user.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field <%= 'field-error' if @user.errors[:name].any? %>">
<%= form.label :name %>
<%= form.text_field :name %>
<% @user.errors[:name].each do |error| %>
<span class="error-hint"><%= error %></span>
<% end %>
</div>
<%= form.submit %>
<% end %>
Validation Contexts
class User < ApplicationRecord
validates :email, presence: true
validates :password, presence: true, on: :create
validates :bio, length: { maximum: 500 }, on: :update
# Custom context
validates :terms, acceptance: true, on: :signup
end
# Usage
User.new(name: "Alice").valid?(:create) # Checks password
User.new(name: "Alice").valid?(:update) # Doesn't check password
Skipping Validations
# Skip validations (use with caution)
user.save(validate: false)
# Update without validations
user.update_attribute(:name, "New Name") # Single attribute, no validation
user.update_columns(name: "New Name") # Multiple attributes, no validation
# Methods that skip validations
user.decrement!(:count)
user.increment!(:count)
user.toggle!(:active)
Common Mistakes
1. Client-Side Only Validation
# Bad — JavaScript validation alone is not enough
# Always validate server-side
# Good — model validations
validates :email, presence: true
2. Not Handling Uniqueness Race Conditions
# Bad — validation only, race condition on concurrent requests
validates :email, uniqueness: true
# Good — validation + database index
add_index :users, :email, unique: true
3. Case Sensitivity in Uniqueness
# Bad — "ALICE@example.com" and "alice@example.com" both pass
validates :email, uniqueness: true
# Good — case insensitive
validates :email, uniqueness: { case_sensitive: false }
4. Overusing Custom Validations
# Bad — custom method when built-in works
validate :name_not_empty
def name_not_empty
errors.add(:name, "can't be blank") if name.blank?
end
# Good — use built-in
validates :name, presence: true
5. Forgetting to Check valid? Before Save
# Bad — assumes save will work
@article = Article.create(params)
# Good — check and respond
@article = Article.new(params)
if @article.save
redirect_to @article
else
render :new, status: :unprocessable_entity
end
Practice Questions
1. What does validates :name, presence: true do?
Ensures the name attribute is not nil and not an empty string. Blank strings and whitespace-only strings are also rejected.
2. How do you validate uniqueness across a scope?
validates :slug, uniqueness: { scope: :user_id } ensures the slug is unique per user, not globally unique.
3. What's the difference between save and save! (with bang)?
save returns false on validation failure. save! raises ActiveRecord::RecordInvalid on validation failure. Use save for conditional flows; use save! when failure should be exceptional.
4. What is a custom validator class?
A class inheriting from ActiveModel::EachValidator that defines validate_each. It's reusable across models, unlike custom validation methods tied to a single model.
Challenge: Create an Order model with validations that ensure items are present, total is positive, and shipping address has required fields, with conditional validation for international shipping.
Solution
class Order < ApplicationRecord
belongs_to :user
has_many :order_items, dependent: :destroy
accepts_nested_attributes_for :order_items
validates :user, presence: true
validates :total, numericality: { greater_than: 0 }
validates :status, inclusion: { in: %w[pending paid shipped delivered cancelled] }
validates :shipping_name, :shipping_address, :shipping_city,
:shipping_country, presence: true
validates :shipping_state, :shipping_zip,
presence: true,
if: -> { shipping_country == "US" }
validates :customs_code, presence: true,
if: -> { shipping_country.present? && shipping_country != "US" }
validates :email, format: { with: /\A[^@\s]+@[^@\s]+\z/ },
unless: -> { email.blank? }
validate :must_have_items
validate :valid_payment, on: :payment
scope :pending, -> { where(status: "pending") }
private
def must_have_items
errors.add(:base, "Order must have at least one item") if order_items.empty?
end
def valid_payment
unless payment_method.present? && payment_token.present?
errors.add(:base, "Payment information is required")
end
end
end
# Usage checks
order = Order.new(user: user)
order.valid? # false — missing fields
order.errors.full_messages
# ["Shipping name can't be blank", "Order must have at least one item", ...]
FAQ
{{< faq question="What is the difference between save and save!?" >}}
save returns true/false. save! raises ActiveRecord::RecordInvalid on failure. Use save in controllers with if/else; use save! in Background Jobs and tests.
{{< /faq >}}
{{< faq question="How do I validate associated records?" >}}
Use validates_associated on the parent. validates :comments, associated: true validates all associated comment records when saving the article.
{{< /faq >}}
{{< faq question="What is the errors object?" >}}
ActiveModel::Errors stores validation errors. record.errors[:field] returns field-specific errors. record.errors.full_messages returns human-readable messages like "Name can't be blank".
{{< /faq >}}
{{< faq question="Can I add errors without a specific attribute?" >}}
Yes. errors.add(:base, "message") adds a general error not tied to any field. Display these as form-level errors.
{{< /faq >}}
{{< faq question="How do I test validations?" >}}
Use valid?, invalid?, and errors methods. assert @user.valid?, assert @user.errors[:email].any?. Use shoulda-matchers gem for one-liner validation tests.
{{< /faq >}}
Try It Yourself
# In Rails console
class Product < ApplicationRecord
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
validates :price, numericality: { greater_than: 0 }
validates :sku, uniqueness: true, format: { with: /\APROD-\d{4}\z/ }
validates :category, inclusion: { in: %w[electronics clothing food] }
end
product = Product.new
product.valid? # false
product.errors.full_messages
# ["Name can't be blank", "Price must be greater than 0", ...]
product.name = "Widget"
product.price = 9.99
product.sku = "PROD-0001"
product.category = "electronics"
product.valid? # true
product.save!
What's Next
Now that you understand validations, learn about testing in Rails with RSpec and system tests.
| Topic | Description | Link |
|---|---|---|
| Ruby Testing | RSpec, factories, system tests | {{< ref "32-testing" >}} |
| Ruby Active Record | ORM, queries, relationships | {{< ref "26-active-record" >}} |
| Python Pytest | Compare Python's testing patterns | Python |
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro