Rails validations in practice
Rails validations are five features wearing one name. The declaration, validates :email, presence:
true, gives nobody any trouble. The other four do: the context that decides whether a declaration
runs at all, the condition that decides the same thing by a different route, the validator object
that the declaration secretly instantiates, and the errors object that is a good deal more
structured than full_messages suggests. Confusion about validations is almost always confusion
about one of those four, and it always has the same shape: a validation that did not run, and
nothing anywhere saying so.
Everything below was run against activerecord 8.1.3.1 and activemodel 8.1.3.1 on Ruby 4.0.5, with PostgreSQL 17.7. Source line numbers are from the installed gems.
on: :create is an if: with a name
Contexts and conditions look like two features and compile to one. From
active_model/validations.rb, in the validate class method:
if options.key?(:on)
options = options.merge(if: [predicate_for_validation_context(options[:on]), *options[:if]])
end
if options.key?(:except_on)
options = options.dup
options[:except_on] = Array(options[:except_on])
options[:unless] = [
->(o) { options[:except_on].intersect?(Array(o.validation_context)) },
*options[:unless]
]
end
Lines 173 and 177. on: becomes an :if, except_on: becomes an :unless, and both end up in the
same callback options as anything you wrote by hand. A Rails validation context is a conditional
validation whose condition is "which name did the caller pass", and the only thing that makes it
different in practice is that the caller passes it rather than the model deciding.
Which matters more than it sounds, and here is the part that bites. A context replaces the default one, it does not add to it:
class Account < ActiveRecord::Base
validates :email, presence: true
validates :slug, presence: true, on: :create
validates :plan, presence: true, on: :update
validates :vat_number, presence: true, on: :billing
end
new record, valid? -> false ["Slug can't be blank"]
new record, valid?(:billing) -> false ["Vat number can't be blank"]
persisted, save -> false ["Plan can't be blank"]
persisted, save(context: :billing) -> false ["Vat number can't be blank"]
The last line is the trap. save(context: :billing) on a persisted record did not check plan. Its
on: :update validator never ran, because validation_context was :billing and not :update.
Anyone reaching for a custom context to add a check has silently removed all the others. valid?
takes an Array for exactly this reason, and validation_context is public on the record and returns
whatever you passed, Array included.
except_on: is new in 8.1 ("Add except_on: option for validation callbacks" in the Active Model
CHANGELOG) and is the honest inverse. validates :vat_number, presence: true, except_on: :create
saved a new record with a blank vat_number and refused the same record on update.
The context a plain form object never enters
ActiveModel::Model and Active Record disagree about what a bare valid? means, and the
disagreement is silent. Active Record overrides valid? so that the default context is
new_record? ? :create : :update. Plain Active Model has no such notion, so the context is nil and
an on: :create validator is skipped. Same two lines of declaration, opposite outcomes:
Account (ActiveRecord) #valid? -> false ["Slug can't be blank"]
Form (ActiveModel::Model) #valid? -> true []
Form (ActiveModel::Model) #valid?(:create) -> false ["Slug can't be blank"]
This is worth knowing because form objects are where on: looks most attractive and works least.
The LaunchKit boilerplate's onboarding steps are ActiveModel::Model classes under app/forms, and
Onboarding::BaseForm#save opens with return false unless valid?, no argument. None of the seven
shipped step forms declares on: today, so nothing there is broken. The point is what happens to the
next person who adds one: the form saves, the spec that exercises the happy path stays green, and
the validation is decoration.
For a form object, use if: or no condition at all. A context on a class whose only caller passes no
context is a validation you have deleted in a way that looks like configuration.
Why conditional validations spread
Conditions written on the validates line apply to every validator on that line, and almost nobody
means that the first time:
validates :vat_number, presence: true, length: { minimum: 4 }, if: :business?
validates :slug, presence: { if: :business? }, length: { minimum: 3 }
plan "solo": ["Slug is too short (minimum is 3 characters)"]
plan "business": ["Vat number is too short (minimum is 4 characters)",
"Slug is too short (minimum is 3 characters)"]
The line-level if: suppressed both the presence and the length check on vat_number for a solo
account. The hash form on slug suppressed only the presence check, and the length check ran for
everybody. Two spellings, two meanings, and the error output is the only place the difference shows.
Combinations are all AND. if: [:business?, :european?] fired for business and FR, and stayed quiet
for business and US and for solo and FR. if: together with unless: is the same: both have to
agree before the validation runs. Strings stopped being accepted some releases ago and now fail
loudly at class definition, which is the one kind soul in this area:
ArgumentError: Passing string to be evaluated in :if and :unless conditional options is not
supported. Pass a symbol for an instance method, or a lambda, proc or block, instead.
Here is the position. Conditions metastasise because each one is cheap to add and free to forget.
if: :business? reads as a fact about the data, and six months later it is a fact about a signup
path, a backfill script and an admin form, none of which the model knows about. Nothing collects
them: there is no Account.conditional_validations to read, the condition is a Symbol inside a
callback, and the only way to answer "did this record get checked" is to reconstruct every predicate
by hand. A context at least appears at the call site, where somebody reading the controller can see
it. Reach for on: when the answer depends on who is saving; reach for if: when it depends on
what the record holds. The test for any given condition is whether a reader of the calling code can
tell that it fired. If business? is a column, if: is right and a context is theatre.
A validator class or a validate method
validates :email, sirename: true is not magic, it is const_get on a camelized string.
active_model/validations/validates.rb line 121 builds "#{key.to_s.camelize}Validator", line 124
resolves it, line 126 gives up:
ArgumentError: Unknown validator: 'SirenameValidator'
Raised at class definition time, which is the good case. What you get for subclassing
ActiveModel::EachValidator is the option plumbing: allow_nil, allow_blank, message, on,
if all work without you writing a line, because active_model/validator.rb line 153 handles them
before your validate_each is called:
next if (value.nil? && options[:allow_nil]) || (value.blank? && options[:allow_blank])
Read that line closely, because allow_nil: true does not skip "". A custom validator declared
with allow_nil passed nil and failed the empty string, which is correct and is not what most
people expect from the option name.
One more thing about validator objects: there is exactly one per declaration, shared by every record.
active_model/validations/with.rb line 93 is validator = klass.new(options.dup, &block), called
once, at class definition. Three records validated in a row and Probe.validators.first.object_id
came back identical all three times. Any instance variable you set in a validator is shared state
across every request in the process.
The choice between a validator class and a plain validate :method has one real criterion, which is
whether a second model will ever use it. The boilerplate has both and they are on opposite sides.
PasswordComplexityValidator is a class, hooked up with validates_with PasswordComplexityValidator
on User, because "what counts as a strong password" is a rule any model with a password wants and
the client-side hints mirror it. It subclasses ActiveModel::Validator rather than EachValidator,
which is the right call for a validator that reads record.password and writes several errors onto
one attribute, since there is no per-attribute loop to run. AiTemplate#variables_are_well_formed is
a private method, because walking that model's variables jsonb column is not a rule anything else
will ever want. The same reasoning that decides whether behaviour belongs in
a concern decides this, and it is the same failure mode when you get it
wrong: a class with one caller, parameterised for callers that never arrived.
The uniqueness race, run twice
Active Record says as much itself, in active_record/validations/uniqueness.rb: "uniqueness checks
on the application level are inherently prone to race conditions", and then, thirty lines later, "In
the rare case that a race condition occurs". The word doing the work there is "rare". It is exactly
as rare as your concurrency, and a double-clicked signup button or a retried Stripe webhook is not
rare at all.
Reproducing it needs the two connections interleaved, which is one callback:
class Account < ActiveRecord::Base
validates :email, uniqueness: true
# Block between the uniqueness SELECT and the INSERT, which is where the window is.
after_validation do
ARRIVED << :here
sleep 0.01 while ARRIVED.size < 2
end
end
Two threads, two pooled connections, same address:
-- validates :email, uniqueness: true, no unique index --
[0, {save: true, errors: [], id: 2}]
[1, {save: true, errors: [], id: 1}]
rows for race@example.com: 2
[[1, "race@example.com"], [2, "race@example.com"]]
Two rows. Both saves returned true, both error arrays empty, nothing logged, nothing raised. Then the same model with one line of migration added:
-- same model, unique index added --
[0, {raised: "ActiveRecord::RecordNotUnique",
message: "PG::UniqueViolation: ERROR: duplicate key value violates unique constraint
\"index_accounts_on_email\""}]
[1, {save: true, errors: [], id: 3}]
rows for race2@example.com: 1
The full exception body carries the DETAIL line too, Key (email)=(dup@example.com) already exists.,
and e.cause is the PG::UniqueViolation underneath. So the index is the guarantee and the
validation is the message, and you want both: without the index you get a duplicate, without the
validation every collision reaches the user as a 500 instead of a form error.
The boilerplate does this consistently, which is the only thing that makes it checkable. Every
uniqueness: true in its models has an index behind it: users.email_address at schema line 327,
ai_templates.key at 60, transactions.stripe_id at 305, stripe_events.stripe_id at 265,
referrals.referred_id at 133, and the two scoped ones,
oauth_identities [provider, uid] at 121 and
email_sequence_enrollments [user_id, sequence_key] at 81. Grepping for uniqueness and checking
each hit against db/schema.rb takes two minutes and is worth doing on any codebase you inherit.
case_sensitive and the index it stops using
The case_sensitive option changed meaning in Rails 6.1 and the new meaning is "whatever your
database collation says", which is not a constant. On this PostgreSQL, with the database created
under the C collation, a plain validates :email, uniqueness: true emits:
SELECT 1 AS one FROM "accounts" WHERE "accounts"."email" = $1 LIMIT $2
With Dan@Example.com already stored, Account.new(email: "dan@example.com").valid? returned
true. Same address to every human and to the mail server, two rows to the index. Ask for
case_sensitive: false and the query changes shape:
SELECT 1 AS one FROM "accounts" WHERE LOWER("accounts"."slug") = LOWER($1) LIMIT $2
That is a function call on the column, so the btree index on slug is unusable. On 200000 rows,
EXPLAIN ANALYZE for the two forms:
= lookup, plain btree
Index Only Scan using index_accounts_on_slug (actual time=0.013..0.013 rows=1 loops=1)
Execution Time: 0.019 ms
LOWER() lookup, same index
Seq Scan on accounts (actual time=16.580..16.580 rows=1 loops=1)
Filter: (lower((slug)::text) = 'slug-199999'::text)
Rows Removed by Filter: 199998
Execution Time: 16.588 ms
A seq scan on every signup, and a seq scan that gets slower every week. CREATE UNIQUE INDEX ... ON
accounts (LOWER(slug)) restores it to 0.017 ms, and has the pleasant side effect of being the only
unique constraint that actually matches what the validation checks.
The boilerplate takes the other road, and the other road is better. User has
normalizes :email_address, with: ->(e) { e.strip.downcase }, so the address is lowercased before it
is ever compared or stored, the validation stays a plain =, and the ordinary unique index on
email_address is both fast and correct. Normalising on write costs you one decision, once, at the
point where the data arrives. Normalising on read costs you an expression index you have to remember
to create and a query plan nobody looks at.
Where the validation and the index disagree about NULL
NULL is the one case where the validator is stricter than the constraint, and it surprises people in
the direction of a false sense of safety. With validates :vat_number, uniqueness: { scope: :country
} and two records whose country is nil, the validator builds:
SELECT 1 AS one FROM "accounts" WHERE "accounts"."vat_number" = $1 AND "accounts"."country" IS NULL LIMIT $2
IS NULL, so it finds the existing row and reports ["Vat number has already been taken"]. A plain
CREATE UNIQUE INDEX ON accounts (vat_number, country) does not, because SQL says two NULLs are not
equal, so the index happily accepted the second row and the table ended up with 2. The application
rule and the database rule disagree about a case the application rule appears to cover.
PostgreSQL 15 added the fix and Rails exposes it:
add_index :accounts, [:vat_number, :country], unique: true, nulls_not_distinct: true emits
... USING btree (vat_number, country) NULLS NOT DISTINCT, and the duplicate insert then raises
ActiveRecord::RecordNotUnique like any other. supports_nulls_not_distinct? is gated on
database_version >= 15_00_00 in postgresql_adapter.rb line 283, and the schema dumper writes the
option out, so it survives a db:schema:load.
The alternative, and often the better one, is a NOT NULL column with a real default. A scope column that can be NULL is usually a modelling decision nobody made on purpose.
create_or_find_by never finds
create_or_find_by stops working the moment the model gains the uniqueness validation it is there to
cope with. The method is built on the exception, from active_record/relation.rb line 273:
def create_or_find_by(attributes, &block)
with_connection do |connection|
record = nil
transaction(requires_new: true) do
record = create(attributes, &block)
record._last_transaction_return_status || raise(ActiveRecord::Rollback)
end
record
rescue ActiveRecord::RecordNotUnique
# ... find_by!(attributes)
Add validates :email, uniqueness: true and the validation stops the INSERT, so the database never
raises, so the rescue never runs. Two models over the same table, one row already present:
Bare: id=1 persisted=true
Validated: id=nil persisted=false errors=["Email has already been taken"]
An unsaved, invalid record handed back from a method whose name promises a persisted one, and
create_or_find_by! raises ActiveRecord::RecordInvalid: Validation failed: Email has already been
taken rather than the RecordNotUnique its rescue is waiting for. No deprecation, no warning, and a
test with an empty table passes.
If you want that method's semantics, do the finding yourself: rescue ActiveRecord::RecordNotUnique
around the save, translate it into errors.add(:email, :taken), and return false. Six lines, and
they work whether or not the validation is there.
errors.add(:base) is not errors.add(:country)
:base is not a special error type, it is an attribute name that no attribute has. Everything else
follows from one method, ActiveModel::Errors#full_message:
errors.full_message(:base, "x") -> "x"
errors.full_message(:country, "x") -> "Country x"
That prefix is the entire mechanical difference. A record carrying one of each reports:
full_messages: ["Pro is not sold in :country yet", "Country is not on the Pro list"]
errors[:base]: ["Pro is not sold in :country yet"]
errors[:country]: ["is not on the Pro list"]
attribute_names: [:base, :country]
Which decides where to put an error, and the rule is about the form rather than about the model. An
error on :country names an input the user can go and change, and every form builder can point at
it. An error on :base names none, so it can only ever be rendered in a summary block. Cross-field
rules go on :base: "this plan is not sold in this country" is not the country field's fault and is
not the plan field's fault. A rule that really is about one attribute goes on that attribute, even
when the code that detects it looks at three columns, which is what
Referral#referrer_is_not_referred does with errors.add(:referred_id, :invalid).
One behaviour to know before you reach for errors.add outside a validation: valid? clears the
errors object first. Three errors added by hand, then a valid? call, and the two the validations
produce are all that is left. Errors are a result, not a store.
Where the message comes from
Passing a Symbol to errors.add sends it through I18n, and the lookup chain is long enough that the
error tells you the whole thing when nothing matches:
Translation missing. Options considered were:
- en.activerecord.errors.models.account.attributes.base.not_a_real_key
- en.activerecord.errors.models.account.not_a_real_key
- en.activerecord.errors.messages.not_a_real_key
- en.errors.attributes.base.not_a_real_key
- en.errors.messages.not_a_real_key
Most specific first, most general last, and the position you choose is a statement about reuse. The
boilerplate uses two of those five and the split is deliberate.
AiTemplate#variables_are_well_formed calls errors.add(:variables, :entry_without_name), and the
message sits at en.activerecord.errors.models.ai_template.attributes.variables.entry_without_name,
the first line of the chain, because "has an entry without a name" is only ever true of that one
jsonb column on that one model. PasswordComplexityValidator calls
errors.add(:password, :no_uppercase), and that message sits at en.errors.messages.no_uppercase,
the last line of the chain, because the validator is written to be attached to any model and a
message pinned to User would go missing the moment it was. Interpolation works from either
position: invalid_source: "has an invalid source '%{source}'" is filled by the source: key passed
to errors.add.
Writing the message inline as a String skips all of this, and for a message with exactly one caller that is a defensible choice. Writing it inline and then needing it in a mailer is how a String becomes a Symbol six months late.
What this does not cover
Scope, so nobody wastes an afternoon. Nothing here is about validates_associated or about how
errors propagate through accepts_nested_attributes_for, both of which deserve their own reading.
Nothing here is about client-side validation, which is a different mechanism with a different failure
mode. Database check constraints, ActiveRecord::CheckViolation and the
validates :status, inclusion: that duplicates one are worth a post and are not this one. And the
interaction between validations and counter columns barely exists,
because update_all and increment_counter do not run validations at all, which is the point of
them.
The one thing to take away is the shape of every failure above: a validation that did not run, or ran against a rule the database does not share. Both are silent by construction. The index, the normalisation and the context you pass at the call site are the three places where the silence stops.
Comments
No comments yet. Be the first.