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:
class Order < ApplicationRecord
validates :shipping_address, presence: true
validates :email, presence: true
endUniqueness validation prevents duplicate values. When building a user system, email addresses must be unique:
class User < ApplicationRecord
validates :email, uniqueness: true
validates :username, uniqueness: { case_sensitive: false }
endNote 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:
class User < ApplicationRecord
validates :password, length: { minimum: 8, maximum: 128 }
validates :bio, length: { maximum: 500, message: "must be under 500 characters" }
endFormat validation uses regular expressions to enforce patterns. Email and phone number fields benefit from format checks:
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" }
endYou can also use the inclusion validator for enumerated values:
class Order < ApplicationRecord
validates :status, inclusion: { in: %w(pending processing shipped delivered cancelled), message: "%{value} is not a valid status" }
endCustom 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:
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
endWhen 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:
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
endThis 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:
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
endUse null: false constraints for fields that require presence:
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
endForeign 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:
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
endWhen 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:
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
endIn your view (ERB), iterate over errors for each field:
<%= 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:
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
endThe 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: truetriggers 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: falsefor 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.






Comments
Loading comments…