Rails Custom Validators: A Practical Guide

Learn Rails custom validators: validation methods, EachValidator classes, options, testing, and database constraints.


By Jean Emmanuel Cadet • 24 min read

Rails Custom Validators: A Practical Guide
Share with friends

Every Rails application eventually runs into a rule that the built-in validators can’t express cleanly. A trial period can’t exceed 30 days. A username can’t collide with a route like /admin. A team plan requires a company email address. These are application-specific rules, and Rails gives you two good tools for them: custom validation methods and custom validator classes.

This guide covers Rails custom validators and Ruby on Rails custom validation from the ground up. You’ll learn how Rails model validations work, when the built-in options are enough, and how to write a validation method with validate. You’ll also learn how to build reusable validators with ActiveModel::EachValidator, pass options to them, test them with RSpec, and decide when a rule belongs in the database or in a different layer of your app. Every example uses one small application so the pieces fit together.


How Rails Model Validations Work

Before writing custom validation logic, it helps to understand what Rails does with the validations you already use. Validations live in ActiveModel::Validations, which Active Record includes. They are application-level checks that run before a record is written to the database.

When validations run

Rails runs validations when you call valid?, invalid?, save, save!, create, create!, update, and update!. If any validation adds an error, valid? returns false, save returns false, and no SQL is sent. The bang versions (save!, create!, update!) raise ActiveRecord::RecordInvalid instead.

Some methods skip validations entirely: save(validate: false), update_column, update_columns, update_all, insert_all, and upsert_all. This matters later, because it is one reason validations alone can’t guarantee data integrity.

Rails also doesn’t stop at the first failure. Every validation runs, so one record can collect several errors at once. That is what lets a form show all problems in one pass.

Where Rails validation errors live

Each model has an errors object. A validation adds entries to it, and valid? simply checks whether it is empty after all validations have run. You can read the results in several ways:

account.errors[:username]            # => ["is reserved"]
account.errors.full_messages # => ["Username is reserved"]
account.errors.details[:username] # => [{ error: :reserved }]

The details hash is useful in tests and APIs because it exposes a machine-readable error type rather than just a sentence. Keep that in mind when we build custom validators.


When Built-in Validations Are Enough

Rails ships with a rich set of validators, and you should reach for them first:

  • presence and absence check that a value is or isn’t blank
  • uniqueness checks that no other record has the same value
  • length checks size with minimum, maximum, in, or is
  • format checks a value against a regular expression
  • numericality checks that a value is a number, with options like greater_than and only_integer
  • inclusion and exclusion check a value against an allowed or forbidden set
  • acceptance checks that a checkbox (like terms of service) was ticked
  • comparison compares a value with another attribute or a fixed value

Here is a short example using several of them on the Account model we’ll build on throughout this article:

class Account < ApplicationRecord
validates :username, presence: true,
length: { in: 3..30 },
format: { with: /\A[a-z0-9_]+\z/, message: "can only contain lowercase letters, numbers, and underscores" },
uniqueness: { case_sensitive: false }

validates :work_email, presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP }

validates :trial_ends_on, comparison: { greater_than: :trial_starts_on },
if: -> { trial_starts_on.present? && trial_ends_on.present? }
end

Each line runs when the record is validated, and each failure adds a message to account.errors under the relevant attribute. Note the comparison validator: it handles “the end date must come after the start date” cleanly, so there is no reason to write a custom method for that.

A built-in validation is enough when the rule is a single, well-known check on a value or a comparison between two attributes. A custom rule is justified when the requirement is specific to your domain, involves several conditions together, or needs the same logic with the same options in more than one model. If you find yourself writing a custom validator that simply wraps inclusion, format, or comparison, stop and use the built-in one. It is better documented, better understood by other Rails developers, and already handles edge cases like nil values.


Custom Validation Methods

The simplest way to add your own rule is a custom validation method. It is a regular instance method on the model, registered with validate (singular), and it adds errors by hand.

The basic shape

Here is a skeleton, not production code, showing the pattern:

class SomeModel < ApplicationRecord
validate :validate_business_rule

private

def validate_business_rule
errors.add(:attribute, "is invalid") unless rule_is_satisfied?
end
end

validate :validate_business_rule tells Rails to call that method every time the record is validated. Inside the method you have full access to the record’s attributes, associations, and other methods, because it is just an instance method. When the rule fails, errors.add(:attribute, "is invalid") records a message against an attribute.

A realistic example: limiting trial length

Our Account has trial_starts_on and trial_ends_on columns. The business rule is that a free trial can’t be longer than 30 days. The comparison validator can check order, but “no more than 30 days apart” combines two attributes with a constant, which is a good fit for a custom method:

class Account < ApplicationRecord
MAX_TRIAL_DAYS = 30

validate :trial_cannot_exceed_maximum_length, if: :trial_dates_present?

private

def trial_dates_present?
trial_starts_on.present? && trial_ends_on.present?
end

def trial_cannot_exceed_maximum_length
return unless (trial_ends_on - trial_starts_on).to_i > MAX_TRIAL_DAYS

errors.add(:trial_ends_on, "must be within #{MAX_TRIAL_DAYS} days of the trial start")
end
end

What happens when this runs

Rails calls trial_cannot_exceed_maximum_length during valid? or save, after the validations declared above it. The method reads two attributes straight from the record and subtracts them (subtracting two Date objects returns the number of days). If the gap is too large, it adds an error to trial_ends_on.

That error is what blocks the record. After all validations run, Rails checks whether errors is empty. It isn’t, so valid? returns false, save returns false, and nothing reaches the database. The if: :trial_dates_present? guard keeps the method from raising a NoMethodError on nil dates, and leaves presence checks to a presence validator where they belong.

Choosing the attribute for the error

The first argument to errors.add decides where the message appears. Attach the error to the attribute the user needs to fix, here trial_ends_on, so the form can show the message next to that field. When a rule involves several fields and no single one is at fault, use :base:

errors.add(:base, "Trial dates conflict with an existing subscription")

Base errors show up in full_messages without an attribute prefix and are usually rendered at the top of a form.

Keep the method focused

A custom validation method should do one thing and read like a sentence. If the method grows long, or you find yourself writing “and also check...” in a comment, split it into several small methods with their own validate lines. Small methods produce precise error messages and are much easier to test. As a rule of thumb, if the method needs more than about ten lines, it is probably doing too much.


Custom Validator Classes with ActiveModel::EachValidator

Custom methods are perfect for rules that belong to one model. But suppose you need the same rule in several models, perhaps with slightly different settings. Copying a private method into each class creates drift: one copy gets fixed, the others don’t. A custom validator class solves this by packaging the rule once and letting any model use it with validates.

Why EachValidator

ActiveModel::EachValidator is the base class designed for validators that check one attribute at a time. It takes care of the plumbing: it receives the options you pass, loops over each attribute listed in validates, honors allow_nil and allow_blank, and calls your validate_each method once per attribute. You only write the rule itself.

A working example: DomainValidator

Our team plan requires a company email, so free providers like Gmail aren’t allowed. Create the file app/validators/domain_validator.rb:

class DomainValidator < ActiveModel::EachValidator
FREE_PROVIDERS = %w[gmail.com yahoo.com outlook.com hotmail.com].freeze

def validate_each(record, attribute, value)
return if value.blank?

domain = value.to_s.split("@").last.to_s.downcase

if FREE_PROVIDERS.include?(domain)
record.errors.add(attribute, :blocked_domain, message: "must use a company domain, not #{domain}")
end
end
end

What record, attribute, and value mean

validate_each always receives three arguments:

  • record is the model instance being validated, so you can read other attributes (record.plan) or add errors to it
  • attribute is the attribute name as a symbol, such as :work_email
  • value is the current value of that attribute, such as "[email protected]"

Because the validator receives the attribute name rather than hardcoding it, the same class works on work_email, billing_email, or any other column in any model.

How errors are added

Inside a validator you add errors to the record, not to self: record.errors.add(attribute, ...). The second argument here is an error type (:blocked_domain), and the message: option supplies the human-readable text. The type shows up in errors.details, which makes tests and API responses more reliable than matching on sentences alone. If you omit the message: option, Rails looks up a translation for the type in your locale files, which is the preferred approach in applications that support multiple languages.

The return if value.blank? line is deliberate. Blank values are the job of presence (or allow_blank), and returning early prevents one empty field from producing two confusing errors.

How Rails discovers and uses the validator

When you write validates :work_email, domain: true, Rails takes the key domain, camelizes it, and appends Validator, producing DomainValidator. It then looks that constant up from the model’s namespace outward and instantiates it with your options (true becomes an empty options hash). So the naming convention is strict: the validates key and the class name must match.

For Zeitwerk, the standard autoloader in modern Rails, file and constant names must match too:

Constant

File

DomainValidator

app/validators/domain_validator.rb

ReservedUsernameValidator

app/validators/reserved_username_validator.rb

Any directory under app/ is autoloaded automatically, so app/validators works without configuration, and code reloads in development as usual. Don’t put validators in lib/ unless you’ve explicitly added it to the autoload paths.

Registering and sharing the validator

Register it on any model with validates:

class Account < ApplicationRecord
validates :work_email, domain: true
end

class Invitation < ApplicationRecord
validates :billing_email, :work_email, domain: true
end

You can list several attributes in one call, and every model that uses the validator shares one implementation. Fix the rule once and every model benefits.

Method or class: how to choose

Use a custom validation method when the rule is specific to one model, reads other attributes of that model, or is unlikely to be reused. Use a validator class when the rule is about a single value that makes sense on several models, when you want to configure it per use with options, or when you want to test the rule in isolation. If a rule needs to look at several attributes at once and is shared across models, validates_with and a class inheriting from ActiveModel::Validator is the better fit, but most day-to-day cases are served by a method or an EachValidator.


Passing Options to Custom Validators

Hardcoded rules are limiting. Options let one validator behave differently in different models without copying code.

A configurable ReservedUsernameValidator

Usernames and handles often become part of a URL, so some words must be off limits. Here is a validator with a sensible default list that each model can extend:

class ReservedUsernameValidator < ActiveModel::EachValidator
DEFAULT_RESERVED = %w[admin administrator root support help api www billing].freeze

def check_validity!
return if options[:extra].nil? || options[:extra].is_a?(Array)

raise ArgumentError, ":extra must be an array of reserved words"
end

def validate_each(record, attribute, value)
return if value.blank?

reserved = (DEFAULT_RESERVED + Array(options[:extra])).map(&:downcase)

if reserved.include?(value.to_s.downcase)
record.errors.add(attribute, :reserved, message: options.fetch(:message, "is reserved"))
end
end
end

And two ways to use it:

class Account < ApplicationRecord
validates :username, reserved_username: true
end

class Organization < ApplicationRecord
validates :handle, reserved_username: { extra: %w[codecurious] }
end

How options reach the validator

Whatever you pass after the key becomes the options hash inside the validator. With reserved_username: true, options is empty. With reserved_username: { extra: %w[codecurious] }, options[:extra] holds the array. Common options such as if, unless, on, allow_nil, and allow_blank are handled by Rails itself, so you only read the options you define. A message: option is conventionally supported, which is why the example falls back to a default with options.fetch.

The optional check_validity! method runs when the validator is created, which is when the model class loads. Using it to reject bad configuration means a typo like extra: "billing" fails immediately at boot instead of silently misbehaving in production.

An honest note on when this earns its place

If you only had one model and a fixed list of reserved words, exclusion: { in: %w[admin root] } combined with normalizes :username, with: ->(name) { name.strip.downcase } would be simpler and perfectly clean. The custom validator is justified here because the same policy (a default list, per-model additions, a shared message, case-insensitive matching) is needed in more than one model. That is the test to apply before writing any validator class: does Rails already handle this cleanly, or does the reuse and configuration justify a dedicated class?

Naming conventions to remember

The option keys are yours to name, so choose descriptive ones (extra, blocked, maximum_days) rather than cryptic abbreviations. The validator class name must end in Validator, and the validates key is the underscored version of the name without that suffix. ReservedUsernameValidator is used as reserved_username:, never reserved: or reservedUsername:.


A Practical End-to-End Example

Let’s assemble the pieces into one coherent Account model, with a migration, a controller, and a form.

The model

The team plan needs a company email, so the domain rule is conditional on the plan. We use an enum for the plan (see Rails enums: a practical guide if you want a deeper look):

class Account < ApplicationRecord
MAX_TRIAL_DAYS = 30

enum :plan, { free: 0, team: 1 }

validates :username, presence: true,
length: { in: 3..30 },
format: { with: /\A[a-z0-9_]+\z/, message: "can only contain lowercase letters, numbers, and underscores" },
uniqueness: { case_sensitive: false },
reserved_username: true

validates :work_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :work_email, domain: true, if: :team?

validates :trial_ends_on, comparison: { greater_than: :trial_starts_on }, if: :trial_dates_present?
validate :trial_cannot_exceed_maximum_length, if: :trial_dates_present?

private

def trial_dates_present?
trial_starts_on.present? && trial_ends_on.present?
end

def trial_cannot_exceed_maximum_length
return unless (trial_ends_on - trial_starts_on).to_i > MAX_TRIAL_DAYS

errors.add(:trial_ends_on, "must be within #{MAX_TRIAL_DAYS} days of the trial start")
end
end

Notice how the built-in validators, the custom validator class, and the custom method sit side by side. They are independent: each runs on every validation pass, and each can add its own error. Use only what the requirement demands: built-ins for presence, length, format, uniqueness, and date order, a validator class for the shared username policy and the domain rule, and a method for the trial-length rule.

A successful validation

account = Account.new(
username: "maria_dev",
work_email: "[email protected]",
plan: :team,
trial_starts_on: Date.new(2026, 11, 1),
trial_ends_on: Date.new(2026, 11, 15)
)

account.valid? # => true
account.errors.empty? # => true

A failed validation

account = Account.new(
username: "admin",
work_email: "[email protected]",
plan: :team,
trial_starts_on: Date.new(2026, 11, 1),
trial_ends_on: Date.new(2026, 12, 15)
)

account.valid? # => false
account.errors.full_messages
# => ["Username is reserved",
# "Work email must use a company domain, not gmail.com",
# "Trial ends on must be within 30 days of the trial start"]

account.errors[:username] # => ["is reserved"]
account.errors.details[:username] # => [{ error: :reserved }]

All three rules failed in one pass, and each error is attached to the right attribute. Those errors are what a controller and a form use to respond.

Responding to failures in the controller and form

class AccountsController < ApplicationController
def new
@account = Account.new
end

def create
@account = Account.new(account_params)

if @account.save
redirect_to @account, notice: "Account created."
else
render :new, status: :unprocessable_entity
end
end

private

def account_params
params.expect(account: [:username, :work_email, :plan, :trial_starts_on, :trial_ends_on])
end
end

When save returns false, the controller re-renders the form with a 422 Unprocessable Entity status, which Turbo (the default in Rails 8) needs in order to display the updated form. The invalid @account carries its errors with it:

<%= form_with model: @account do |form| %>
<% if @account.errors.any? %>
<div role="alert">
<h2><%= pluralize(@account.errors.count, "error") %> prevented this account from being saved:</h2>
<ul>
<% @account.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>

<%= form.label :username %>
<%= form.text_field :username %>

<%= form.label :work_email %>
<%= form.email_field :work_email %>

<%= form.submit %>
<% end %>

In production, you’ll usually also show messages next to individual fields using @account.errors[:username]. The architecture stays small: one model, two validators, one method, and a standard controller. No extra layers are needed for a requirement this simple. If a single form needs to validate data spanning several models, that is the point to look at form objects and when to use them.


Conditional Validations and Validation Contexts

Not every rule applies to every record in every situation. Rails lets you control when a validation runs, and this works for built-in validators, custom validators, and custom methods alike.

Using if and unless

if and unless accept a symbol, a lambda, or a method name:

validates :work_email, domain: true, if: :team?                      # symbol
validate :trial_cannot_exceed_maximum_length, unless: :free? # symbol with unless
validates :username, reserved_username: true, if: -> { username_changed? } # lambda

A symbol refers to an instance method, which is the clearest option because the method name documents the intent (team?, trial_dates_present?). Lambdas are good for short one-liners. When the condition has real logic, give it a named method. You can also pass arrays of conditions, and all of them must be true.

Conditions that check username_changed? have a practical benefit: if a validator is expensive, running it only when the attribute changed avoids repeating work on every save of an unrelated field.

Validation contexts

Contexts let you run a validation only in specific situations. Rails provides :create and :update automatically:

validates :trial_starts_on, presence: true, on: :create

You can also define your own context and pass it when validating:

validates :work_email, domain: true, on: :onboarding

account.valid?(:onboarding)
account.save(context: :onboarding)

Two details are worth remembering. Validations without an on: option still run in every context, including custom ones. And when you pass a custom context, validations marked on: :create or on: :update do not run, because the context replaces the default one.

When conditions help and when they hurt

Conditional validation improves clarity when the condition mirrors a real business distinction, like “team accounts need a company email”. It hurts when conditions pile up. If a model has validations gated by five different flags, nobody can predict whether a given record is valid without tracing through them all. Watch for conditions that depend on hidden or global state such as Current.user or Time.current, which make results differ between runs. If different workflows need genuinely different validation rules for the same model, contexts or a separate form object are usually clearer than a tangle of if options.


Custom Validations and Database Constraints

Validations improve the user experience: they check data before saving and produce friendly messages that a form can display. But they are not a guarantee of data integrity.

Why validations alone aren’t enough

A validation like uniqueness works in two steps: it runs a SELECT to see if another record has the value, then Rails runs the INSERT. Between those two steps, a second request can insert the same value. With two concurrent requests, both validations pass and both inserts succeed, leaving duplicate usernames. Skipped validations (update_column, insert_all, raw SQL, console sessions, other applications sharing the database) can also write invalid data without any Rails check.

Back important rules with the database

When an invariant must always hold, enforce it in the database too. Here is a migration for our Account table:

class CreateAccounts < ActiveRecord::Migration[8.0]
def change
create_table :accounts do |t|
t.string :username, null: false
t.string :work_email, null: false
t.integer :plan, null: false, default: 0
t.date :trial_starts_on
t.date :trial_ends_on

t.timestamps
end

add_index :accounts, "lower(username)", unique: true, name: "index_accounts_on_lower_username"
add_check_constraint :accounts, "trial_ends_on > trial_starts_on", name: "accounts_trial_dates_order"
end
end

That migration produces SQL equivalent to this (PostgreSQL syntax):

CREATE UNIQUE INDEX index_accounts_on_lower_username ON accounts (lower(username));

ALTER TABLE accounts
ADD CONSTRAINT accounts_trial_dates_order CHECK (trial_ends_on > trial_starts_on);

Each piece pairs with a validation:

  • null: false backs validates :username, presence: true
  • The unique index on lower(username) backs uniqueness: { case_sensitive: false }, and the expression matches the case-insensitive validation
  • The check constraint backs the date order rule from comparison

A check constraint allows rows where either date is NULL, which matches our validation guard. Notice that the 30-day maximum is not in the database. You can add that too if it’s a true invariant, but a rule that might change with pricing decisions is often fine as an application-level rule only. For more on how indexes work and how to choose them, see database indexes in Rails.

Handling the database error gracefully

Because the validation and the constraint are separate layers, the database can still reject a write that passed validation. Handle that case so users see a message instead of a 500 error:

def create
@account = Account.new(account_params)

if @account.save
redirect_to @account, notice: "Account created."
else
render :new, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotUnique
@account.errors.add(:username, :taken)
render :new, status: :unprocessable_entity
end

The validation still gives fast, friendly feedback in the common case. The index guarantees correctness in the rare race. If you write several records together and need all-or-nothing behavior, wrap them in a transaction, as covered in Rails transactions: a practical guide.

The rule of thumb: a custom validator is not a replacement for a database constraint when the database must enforce an invariant. Use both: the validator for user feedback, the constraint for the guarantee.


Testing Custom Validators

Test what a validator does, not how it does it. The observable behavior is: does the record become invalid, and which errors does it carry? Tests written that way survive refactoring.

Testing a validator class in isolation

A validator class can be tested without any database table by using a small throwaway class. This helper builds one, optionally with validator options:

# spec/validators/reserved_username_validator_spec.rb
require "rails_helper"

RSpec.describe ReservedUsernameValidator do
def build_model(username, **validator_options)
options = validator_options.presence || true

Class.new do
include ActiveModel::Validations
attr_accessor :username

def self.name = "Profile"

validates :username, reserved_username: options
end.new.tap { |model| model.username = username }
end

it "accepts an ordinary username" do
expect(build_model("maria_dev")).to be_valid
end

it "rejects a reserved username with a clear message" do
model = build_model("admin")

expect(model).not_to be_valid
expect(model.errors[:username]).to include("is reserved")
end

it "matches reserved words regardless of case" do
expect(build_model("ADMIN")).not_to be_valid
end

it "ignores blank values and leaves them to presence validation" do
expect(build_model("")).to be_valid
expect(build_model(nil)).to be_valid
end

it "uses the extra words passed as options" do
expect(build_model("codecurious", extra: %w[codecurious])).not_to be_valid
expect(build_model("codecurious")).to be_valid
end

it "supports a custom message" do
model = build_model("admin", message: "is not available")

expect(model).not_to be_valid
expect(model.errors[:username]).to include("is not available")
end
end

The anonymous class defines self.name because Active Model needs a model name to build error messages. This single spec covers valid input, invalid input, the expected message, a blank boundary case, and custom options.

Testing custom methods and boundaries

For the trial rule, boundaries matter most. Test the last allowed value and the first disallowed one. These examples assume a FactoryBot :account factory that produces a valid record:

# spec/models/account_spec.rb
require "rails_helper"

RSpec.describe Account, type: :model do
describe "trial length" do
let(:start_date) { Date.new(2026, 11, 1) }

it "allows a trial of exactly 30 days" do
account = build(:account, trial_starts_on: start_date, trial_ends_on: start_date + 30)

expect(account).to be_valid
end

it "rejects a trial of 31 days" do
account = build(:account, trial_starts_on: start_date, trial_ends_on: start_date + 31)

expect(account).not_to be_valid
expect(account.errors[:trial_ends_on]).to include("must be within 30 days of the trial start")
end

it "does not raise when trial dates are missing" do
account = build(:account, trial_starts_on: nil, trial_ends_on: nil)

expect(account).to be_valid
end
end

describe "work email domain" do
it "rejects free providers on the team plan" do
account = build(:account, plan: :team, work_email: "[email protected]")

expect(account).not_to be_valid
expect(account.errors[:work_email]).to include("must use a company domain, not gmail.com")
end

it "allows free providers on the free plan" do
account = build(:account, plan: :free, work_email: "[email protected]")

expect(account).to be_valid
end
end
end

Testing reuse across models

To prove the validator really is shared, add a short spec to the second model:

# spec/models/organization_spec.rb
require "rails_helper"

RSpec.describe Organization, type: :model do
it "rejects reserved handles, including its own extra words" do
organization = build(:organization, handle: "codecurious")

expect(organization).not_to be_valid
expect(organization.errors[:handle]).to include("is reserved")
end
end

Notice that the model specs assert on valid? and on errors, never on which private method ran. That keeps tests focused on behavior. If a validator becomes hard to test, that is usually a sign it is doing too much.


Common Mistakes and Best Practices

Most validation problems come from a handful of habits. Here are the ones worth avoiding.

Creating a custom validator for a rule Rails already supports. Before writing a class, check whether comparison, inclusion, exclusion, format, or numericality does the job. Built-ins are familiar to every Rails developer.

Putting too many unrelated rules into one validator. A validator named AccountValidator that checks usernames, emails, and dates is hard to name, configure, and test. Prefer small validators that each answer one question.

Mutating attributes during validation. A validation should only read and report. Lowercasing a username or stripping whitespace inside validate_each changes the record as a side effect of a check. Use normalizes or a before_validation callback for cleanup.

Performing database writes during validation. Validation can run many times (valid? in a form preview, on every save attempt), so writes inside it produce surprising duplicates and are not rolled back if the save is abandoned.

Calling external APIs synchronously during validation. A slow or failing third-party service would now block or break every save. Treat external checks as separate, asynchronous work and store the result.

Making validation depend on hidden state. Rules that read Current.user, the clock, or environment variables behave differently across contexts, making them hard to reproduce and test. Pass what you need through attributes or explicit options.

Ignoring database constraints. As covered above, a validator can’t stop a race condition or a bypassed callback. Back important invariants with the database.

Writing vague or misleading error messages. “is invalid” tells a user nothing. “must use a company domain, not gmail.com” tells them exactly what to change. Attach the message to the attribute the user can edit.

Creating complex validators that are difficult to test. If you need elaborate setup to exercise a validator, simplify it or split it up.

Putting business workflows inside validators. Sending an email, charging a card, or creating a related record does not belong in validation. Validation should determine whether data meets defined rules, not execute unrelated business processes.

The best practice that ties these together: keep validators focused, predictable, reusable where appropriate, and easy to test. A good validator takes a value (and its options), answers yes or no, and describes any problem clearly.


Performance and Maintainability

Validations run on every save, so small inefficiencies add up.

Avoiding unnecessary work

Queries inside validators are the most common performance problem. uniqueness already issues a query, and a custom validator that adds another on every save is costly. Use conditions to skip work when it isn’t needed, such as if: :username_changed?, so an unrelated update doesn’t repeat the check. Avoid loading associations inside a validator unless the rule truly requires them, and be careful about validators that iterate over collections, which can create N+1 queries when many records are validated in a loop.

For repeated expensive computations, compute once and reuse. Keep constant lists frozen, and if a list grows large, store it as a Set so membership checks stay fast. A short array like our reserved words list is fine as is.

Keeping validations small and non-duplicated

Small validation methods are easier to read, test, and reuse. When the same rule appears in two models, extract it into a validator class instead of copy-pasting. Duplicated rules drift apart, and drifting rules are bugs waiting to happen. Conversely, don’t extract too early: a rule used once is perfectly fine as a private method on its model.

Handling external dependencies carefully

If a rule needs outside information, such as checking whether an email domain has valid MX records, don’t call the network inside the validation. Do the lookup in a background job after the record is saved and store the outcome in a column, or ask the user to confirm. Validation should stay fast and deterministic.

When validation logic belongs somewhere else

A rule belongs in a model validator when it describes the validity of that record’s own data. It likely belongs elsewhere when it depends on other aggregates (such as “an organization can have at most five free accounts”), involves multiple models submitted together, or triggers side effects. Those cases fit a form object or a service object better, and sometimes a database constraint. For multi-model forms, revisit form objects. Choosing between a local custom method and a shared validator class follows the same logic as before: local for one model, shared class when the rule and its options genuinely repeat.


Conclusion

Rails custom validations are useful when application-specific rules go beyond built-in validations. Start with the built-in validators, and reach for custom ones only when a requirement can’t be expressed cleanly. Use custom validation methods with validate and errors.add for focused, model-specific rules, and build reusable validator classes with ActiveModel::EachValidator when the same rule belongs across multiple models. Keep validators small, give errors clear messages on the right attribute, and test the observable behavior. Finally, remember that validations give users feedback but don’t guarantee integrity: use database constraints when data integrity must be enforced at the database level.

Jean Emmanuel Cadet
Written by Jean Emmanuel Cadet
Jean Emmanuel is a Full-Stack Software Engineer specializing in Ruby on Rails and the modern Rails ecosystem. He builds scalable, maintainable web applications using Rails, Hotwire (Turbo & Stimulus), PostgreSQL, and SQLite, with a focus on fast, dynamic user experiences. Through CodeCurious, he shares practical lessons, development insights, and real-world solutions for modern developers.

Coder. Apprendre. Progresser.

Une newsletter conviviale où je partage des conseils, des leçons et des réussites de mon parcours de développeur.