Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 012 / 100

Validations in Rails: a Ruby on Rails guide for developers

Keep invalid data out.

Expona Admin7 min read

Introduction

Invalid data slipping into your database creates cascading problems downstream—corrupted reports, failed transactions, and confused customers. Validations in Rails: a Ruby on Rails guide for developers teaches you how to enforce data integrity at the application level before anything touches your database. We'll walk through Rails' built-in validation helpers, show you when and how to write custom validation methods, and explain why database constraints belong in your toolkit alongside application validations.

Presence, Uniqueness, Length, and Format Validators

Rails gives us battle-tested validators out of the box. These cover the majority of real-world scenarios and should be your first line of defense.

Presence validation ensures a field isn't blank. In an e-commerce system, you can't process an order without a shipping address:

ruby
class Order < ApplicationRecord
  validates :shipping_address, presence: true
  validates :email, presence: true
end

Uniqueness validation prevents duplicate values. When building a user system, email addresses must be unique:

ruby
class User < ApplicationRecord
  validates :email, uniqueness: true
  validates :username, uniqueness: { case_sensitive: false }
end

Note that uniqueness validation checks the database, so it's not instantaneous—and it's vulnerable to race conditions in high-concurrency scenarios. Always pair it with a unique database constraint.

Length validation restricts string or array size. A password field might require between 8 and 128 characters:

ruby
class User < ApplicationRecord
  validates :password, length: { minimum: 8, maximum: 128 }
  validates :bio, length: { maximum: 500, message: "must be under 500 characters" }
end

Format validation uses regular expressions to enforce patterns. Email and phone number fields benefit from format checks:

ruby
class Contact < ApplicationRecord
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :phone, format: { with: /\A\d{3}-\d{3}-\d{4}\z/, message: "must be XXX-XXX-XXXX" }
end

You can also use the inclusion validator for enumerated values:

ruby
class Order < ApplicationRecord
  validates :status, inclusion: { in: %w(pending processing shipped delivered cancelled), message: "%{value} is not a valid status" }
end

Custom Validation Methods

Built-in validators handle common cases, but your domain logic often demands custom validations. Writing custom validators keeps business rules explicit and testable.

Imagine a ride-hailing system where a driver can't accept a ride if they're already on an active trip. Define a custom validation method:

ruby
class RideAcceptance < ApplicationRecord
  belongs_to :driver
  belongs_to :ride

  validate :driver_not_already_on_trip

  private

  def driver_not_already_on_trip
    if driver.active_ride.present?
      errors.add(:driver, "is already on an active trip")
    end
  end
end

When validation fails, the model doesn't save and the error message is added to the errors collection. In a controller, you can then render the form again with error details.

For more complex or reusable validations, extract them into a custom validator class:

ruby
class CreditCardValidator < ActiveModel::Validator
  def validate(record)
    unless valid_luhn?(record.card_number)
      record.errors.add :card_number, "is invalid"
    end
  end

  private

  def valid_luhn?(number)
    digits = number.to_s.scan(/\d/).map(&:to_i)
    sum = 0
    digits.reverse.each_with_index do |digit, index|
      digit *= 2 if index.odd?
      digit -= 9 if digit > 9
      sum += digit
    end
    sum % 10 == 0
  end
end

class Payment < ApplicationRecord
  validates_with CreditCardValidator
end

This approach scales: validators live in app/validators/ and can be reused across multiple models.

Database Constraints: Your Safety Net

Application validations are a first layer of defense, but they're not enough. A buggy code path, a race condition, or someone bypassing your API entirely can still corrupt your database. Database constraints are your last line of defense.

Always add a unique constraint at the database level when you use uniqueness validation:

ruby
class CreateUsers < ActiveRecord::Migration[7.0]
  def change
    create_table :users do |t|
      t.string :email, null: false
      t.string :username, null: false
      t.timestamps
    end
    add_index :users, :email, unique: true
    add_index :users, :username, unique: true
  end
end

Use null: false constraints for fields that require presence:

ruby
class CreateOrders < ActiveRecord::Migration[7.0]
  def change
    create_table :orders do |t|
      t.references :user, null: false, foreign_key: true
      t.string :status, null: false, default: "pending"
      t.decimal :total, null: false, precision: 10, scale: 2
      t.timestamps
    end
  end
end

Foreign key constraints ensure referential integrity in relational schemas. When you define references with foreign_key: true, the database prevents orphaned records.

For domain-specific rules, use check constraints:

ruby
class CreateInventoryItems < ActiveRecord::Migration[7.0]
  def change
    create_table :inventory_items do |t|
      t.integer :quantity, null: false
      t.timestamps
    end
    add_check_constraint :inventory_items, "quantity >= 0", name: "quantity_non_negative"
  end
end

When a constraint violation occurs, Rails raises an ActiveRecord::StatementInvalid (or a database-specific error). Handle this gracefully in your controllers.

Displaying Errors in Forms

Validation errors are only useful if your users see them. Rails makes it straightforward to display errors next to form fields.

In your controller, attempt to save and re-render the form on failure:

ruby
class UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    if @user.save
      redirect_to @user, notice: "User created successfully"
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def user_params
    params.require(:user).permit(:email, :username, :password)
  end
end

In your view (ERB), iterate over errors for each field:

ruby
<%= form_with(model: @user, local: true) do |form| %>
  <% if @user.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@user.errors.count, "error") %> prohibited this user from being saved:</h2>
      <ul>
        <% @user.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.label :email %>
    <%= form.email_field :email %>
    <% if @user.errors[:email].any? %>
      <span class="error"><%= @user.errors[:email].join(", ") %></span>
    <% end %>
  </div>

  <div class="field">
    <%= form.label :password %>
    <%= form.password_field :password %>
    <% if @user.errors[:password].any? %>
      <span class="error"><%= @user.errors[:password].join(", ") %></span>
    <% end %>
  </div>

  <%= form.submit "Create User" %>
<% end %>

For modern UX, consider rendering errors via JSON in API responses:

ruby
class UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    if @user.save
      render json: @user, status: :created
    else
      render json: { errors: @user.errors.as_json }, status: :unprocessable_entity
    end
  end
end

The as_json method formats errors in a structure your frontend can easily consume and display.

Common Mistakes

  • Relying solely on application validations: A unique index at the database level will save you from race conditions and accidental data corruption. Always pair your validators with constraints.
  • Overly complex custom validations: If a validation requires querying multiple tables or performing expensive operations, consider moving it to a service layer or background job. Keep validators fast.
  • Forgetting to handle validation errors in APIs: JSON API responses must clearly communicate which fields failed validation and why. Use a consistent error response format across your endpoints.
  • Not testing validations: Validations are logic and deserve unit tests. Write specs for edge cases—boundary lengths, special characters in format validations, race condition scenarios.
  • Ignoring the cost of uniqueness checks: Every validates :email, uniqueness: true triggers a database query. In high-concurrency systems, consider whether you truly need application-level uniqueness validation if you have a database constraint.

Key Takeaways

  • Use Rails' built-in validators—presence, uniqueness, length, format—for straightforward constraints; they're battle-tested and efficient.
  • Write custom validation methods when enforcing business logic; keep them focused and fast.
  • Always back application validations with database constraints: unique indexes for uniqueness, null: false for presence, foreign keys for referential integrity, and check constraints for domain rules.
  • Display validation errors clearly in forms and API responses so users understand what went wrong.
  • Test your validations thoroughly; they're a critical part of your data integrity story.
  • Remember that validations prevent invalid data from being saved, but constraints prevent it from ever reaching your database, regardless of how the data got there.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles