Skip to content

Ruby on Rails Validations — Presence Uniqueness Custom and Callbacks Explained

DodaTech Updated 2026-06-28 7 min read

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