Ruby on Rails Module 2 – Active Record: models and the database
Validations, errors and normalization
Validate presence, format and uniqueness in Active Record, read the errors object, and normalize input so a value is stored and queried in one shape.
What you will learn
- Read the errors object after a failed save with valid? and errors.full_messages
- Choose a validation context with on: :create and custom contexts
- Apply normalizes so a value is stored and queried in one shape
- Explain why a uniqueness validation can lose a race, and back it with a unique index
- Write custom and conditional rules, and report an unknown enum value as a validation error
Before you start
What you’ll learn · Before you start
What you will learn
- Read the errors object after a failed save with valid? and errors.full_messages
- Choose a validation context with on: :create and custom contexts
- Apply normalizes so a value is stored and queried in one shape
- Explain why a uniqueness validation can lose a race, and back it with a unique index
- Write custom and conditional rules, and report an unknown enum value as a validation error
Before you start
On this page
A sign-up form for Kaksha sends a student’s name, mobile number and grade to Student.create. Some of it will be
blank, some mistyped, one mobile will arrive as +91 98765-43210 and another as 098765 43210, and two requests
will enrol the same student in the same batch at the same second. A validation: A rule Active Record checks before it saves a record; a rule that fails adds a message to the record's errors and makes save return false instead of writing the row. is how a
model refuses
the bad input and explains itself; a normalization: Cleaning an attribute into one canonical shape with normalizes, which runs when the value is assigned and also when it is used to query that attribute. is how it cleans the input that is
fine but untidy.
This lesson works through both on recorded runs: how save fills the errors object and when each rule runs, how
normalizes cleans a value on the way in and on the way to a query, why a uniqueness rule still needs a unique index,
and how to write rules Rails has no built-in for.
Validations run on save and fill the errors object
Declare the rules on the model, and Active Record checks them every time you save, create or update. A failing
rule adds a message to the record’s errors object instead of raising, so the save returns false and the form can
show what went wrong. This Student carries one rule of each common kind; the program below builds a blank student
and a student with one bad value per rule, and prints what errors holds:
# How validations fill the errors object on save, and how to read it.
# Standalone Active Record (no Rails app): an in-memory SQLite database and
# one Student model carrying the usual rules. Nothing here talks to a server.
require "active_record"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :students do |t|
t.string :name
t.string :mobile
t.integer :grade
t.string :status, default: "enquiry"
t.string :guardian_name
t.timestamps
end
end
class Student < ActiveRecord::Base
STATUSES = %w[enquiry enrolled left].freeze
validates :name, presence: true, length: { maximum: 80 }
validates :mobile,
format: { with: /\A[6-9]\d{9}\z/, message: "must be a 10-digit Indian mobile" }
validates :grade, numericality: { only_integer: true, in: 1..12 }
validates :status, inclusion: { in: STATUSES }
# Runs only when a record is created, not on later updates.
validate :guardian_on_create, on: :create
def guardian_on_create
errors.add(:guardian_name, :blank, message: "is needed when a student joins") if guardian_name.blank?
end
end
def show(label, record, context = nil)
ok = context ? record.valid?(context) : record.valid?
puts "#{label}: valid?#{context ? "(#{context})" : ""} -> #{ok}"
record.errors.full_messages.each { |m| puts " - #{m}" }
end
puts "== A blank new student =="
blank = Student.new
show("new, nothing set", blank)
puts
puts "== Bad values, one per rule =="
bad = Student.new(name: "x" * 90, mobile: "12345", grade: 15, status: "guest", guardian_name: "Asha")
show("too long, bad mobile, bad grade, bad status", bad)
puts
puts "== The error is more than a sentence =="
puts "errors[:mobile] -> #{bad.errors[:mobile].inspect}"
puts "errors.details[:grade] -> #{bad.errors.details[:grade].inspect}"
puts "the :grade symbol is what the i18n layer turns into a message"
puts
puts "== save returns false and writes no row =="
saved = bad.save
puts "bad.save -> #{saved}; rows in students: #{Student.count}"
puts
puts "== Context decides which rules run =="
ok = Student.create!(name: "Ravi Kumar", mobile: "9876543210", grade: 10, guardian_name: "Sita Kumar")
puts "created id=#{ok.id}, rows: #{Student.count}"
ok.guardian_name = ""
puts "update without guardian -> valid? #{ok.valid?} (default context: the create-only rule is skipped)"
puts "valid?(:create) -> #{ok.valid?(:create)} (the create-only rule runs again)"
ok.errors.full_messages.each { |m| puts " - #{m}" } Output
== A blank new student ==
new, nothing set: valid? -> false
- Name can't be blank
- Mobile must be a 10-digit Indian mobile
- Grade is not a number
- Guardian name is needed when a student joins
== Bad values, one per rule ==
too long, bad mobile, bad grade, bad status: valid? -> false
- Name is too long (maximum is 80 characters)
- Mobile must be a 10-digit Indian mobile
- Grade must be in 1..12
- Status is not included in the list
== The error is more than a sentence ==
errors[:mobile] -> ["must be a 10-digit Indian mobile"]
errors.details[:grade] -> [{error: :in, value: 15, count: 1..12}]
the :grade symbol is what the i18n layer turns into a message
== save returns false and writes no row ==
bad.save -> false; rows in students: 0
== Context decides which rules run ==
created id=1, rows: 1
update without guardian -> valid? true (default context: the create-only rule is skipped)
valid?(:create) -> false (the create-only rule runs again)
- Guardian name is needed when a student joins
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby validation_errors.rb
The blank student failed four rules at once: Name can't be blank, the mobile format, Grade is not a number and the
guardian rule. The student with bad values shows the other messages: Name is too long (maximum is 80 characters) and
Status is not included in the list. bad.save returned false and the row count stayed at zero, so a record that
fails validation never reaches the table.
errors is more than a list of sentences. errors[:mobile] gives just that attribute’s messages, and
errors.details[:grade] gives [{error: :in, value: 15, count: 1..12}]: the symbol :in is what the i18n layer turns
into the message, so an app translated into another language shows the right words without changing the rule. Step
through what save does with all of this:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The call begins. A call to save, or to valid? on its own, starts the checks on the record. Nothing has touched the database yet.
- Each rule runs in turn. Active Record runs every rule you declared on the model, one after another: presence, format, length, numericality, inclusion, uniqueness and any of your own.
- Failures collect in errors. A rule that fails adds a message to the record's errors object, keyed by the attribute. A record that passes every rule adds nothing, so errors stays empty.
- Any error stops the save. If errors holds anything, save makes no change to the database and returns false, so the caller can show the messages. In the recorded run the row count stayed at zero.
- An empty errors lets it through. If errors is empty, the INSERT or UPDATE runs and save returns true. Validations are the gate the row passes through only when every rule is happy.
Key idea
A validation adds a message to errors rather than raising, so save returns false and nothing is written; you
read errors.full_messages for the user and errors.details for the i18n symbol.
Checkpoint
Check: Validations fill errors on save
A context decides which rules run
Some rules should run only when a record is first created. A guardian’s name is needed when a student joins, but an old
record edited years later should not be refused for it. validate :guardian_on_create, on: :create runs that rule only
in the create context. The recorded run shows the effect: after the student was created, clearing the guardian name and
calling valid? returned true, because the default context skips the create-only rule, while valid?(:create)
returned false and brought the message back.
That rule also shows errors.add with a symbol: errors.add(:guardian_name, :blank, message: "…"). The symbol
:blank names the kind of error, which errors.details reports, and the i18n key of the message. The message: is
the fallback text: Rails shows it unless the locale file translates the key for this model and attribute,
activerecord.errors.models.student.attributes.guardian_name.blank. So pass a symbol even with your own text: a
client that reads errors.details gets :blank, and a translation added later replaces the words without touching
the rule.
| When you call | Rules that run |
|---|---|
valid? or save on a saved record |
Every rule with no on:, plus those marked on: :update |
valid?(:create) or create |
Every rule with no on:, plus those marked on: :create |
valid?(:publish) (a custom context) |
Every rule with no on:, plus those marked on: :publish |
Name the context for the moment, not the action
A custom context such as on: :enrolment lets one model carry rules for several moments in a record’s life: the
light checks a draft needs, and the stricter checks that only matter when a student is actually enrolled.
Checkpoint
Check: Contexts choose which rules run
normalizes cleans a value before it is stored or queried
Validation rejects bad input; normalization tidies input that is fine but untidy, so
you are not storing the same mobile number in five shapes. normalizes :phone, with: ->(value) { … } runs the block
when the attribute is assigned, so the column keeps one canonical shape. Active Record also runs it on a value used to
query that attribute, so a lookup finds the row however the number was typed:
# normalizes runs on assignment and also when a value is used to query, so a
# phone number is stored and looked up in one canonical shape. Standalone
# Active Record with an in-memory SQLite database; no server, no network.
require "active_record"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :guardians do |t|
t.string :name
t.string :phone
t.timestamps
end
add_index :guardians, :phone, unique: true
end
class Guardian < ActiveRecord::Base
# Keep digits only, drop a leading 0 or 91, then store as +91 and ten digits.
normalizes :phone, with: ->(value) do
digits = value.to_s.gsub(/\D/, "")
digits = digits.delete_prefix("0")
digits = digits.delete_prefix("91") if digits.length == 12
"+91#{digits}"
end
validates :phone, format: { with: /\A\+91[6-9]\d{9}\z/, message: "must be an Indian mobile" }
end
puts "== One number entered four ways, stored once =="
["+91 98765-43210", "098765 43210", "919876543210", "9876543210"].each do |typed|
g = Guardian.new(phone: typed)
printf " typed %-18s stored %s\n", typed.inspect, g.phone.inspect
end
g = Guardian.create!(name: "Sita Kumar", phone: "+91 98765-43210")
puts "\nsaved id=#{g.id}, phone=#{g.phone.inspect}"
puts "\n== Looked up three ways, each query normalized first =="
[
'98765 43210',
'0 98765 43210',
'+91-98765-43210',
].each do |typed|
found = Guardian.find_by(phone: typed)
puts " find_by(phone: #{typed.inspect}) -> #{found&.name.inspect}"
end
puts "\n== A number that is not a mobile is refused =="
bad = Guardian.new(name: "Front desk", phone: "011 2345 6789")
puts " phone stored as #{bad.phone.inspect}; valid? #{bad.valid?}"
bad.errors.full_messages.each { |m| puts " - #{m}" } Output
== One number entered four ways, stored once == typed "+91 98765-43210" stored "+919876543210" typed "098765 43210" stored "+919876543210" typed "919876543210" stored "+919876543210" typed "9876543210" stored "+919876543210" saved id=1, phone="+919876543210" == Looked up three ways, each query normalized first == find_by(phone: "98765 43210") -> "Sita Kumar" find_by(phone: "0 98765 43210") -> "Sita Kumar" find_by(phone: "+91-98765-43210") -> "Sita Kumar" == A number that is not a mobile is refused == phone stored as "+911123456789"; valid? false - Phone must be an Indian mobile
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby phone_normalization.rb
All four typed forms were stored as +919876543210. The three lookups each used a differently typed number, and each
found Sita Kumar, because the query value went through the same cleaner before the WHERE. A number that is not a
mobile, 011 2345 6789, was still normalized to +911123456789 and then failed the format validation: normalizing and
validating are two steps, and you want both. Follow one value in and one value out to a query:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A phone arrives from a form. A guardian's phone comes in however the user typed it: with a country code, spaces, a hyphen or a leading zero. Here it is +91 98765-43210.
- normalizes cleans it. As the value is assigned, the normalizes block runs: it keeps only the digits, drops a leading 0 or a leading 91, then puts the number back as +91 and ten digits.
- The column keeps one shape. Whatever was typed, the stored value is +919876543210. Every guardian's phone is kept in this one shape, which is also what the unique index compares.
- A lookup uses another shape. Later someone looks the guardian up with find_by(phone: "98765 43210"), a value typed yet another way.
- The query is cleaned too. Active Record runs the same normalizes block on the query value before it builds the WHERE, so the lookup searches for +919876543210.
- The cleaned query matches. The cleaned query equals the stored value, so the row is found. The recorded run found Sita Kumar by three differently typed numbers.
What counts as a mobile here
An Indian mobile number is ten digits beginning 6, 7, 8 or 9, often written with a +91 or 0 prefix and spaces or
hyphens. The normalizer strips those to store +91 and the ten digits; the format rule then refuses anything that is
not a mobile.
Keep the two jobs apart in your head: the cleaner makes the stored shape predictable, and the rule decides whether the value is allowed at all. One without the other leaves either a mess of shapes or a tidy but wrong value.
Key idea
normalizes runs on assignment and on a query of that attribute, so a value is stored and searched for in one shape;
it does not replace validation, which still checks the cleaned value.
Checkpoint
Check: normalizes on assignment and query
Uniqueness is a check before the insert, so it can lose a race
validates :student_id, uniqueness: { scope: :batch_id } reads well, but it is a SELECT that runs just before the
INSERT. If two requests enrol the same student in the same batch at the same second, both run the SELECT while the
table is still empty, both pass, and both go on to INSERT. The uniqueness validation: The uniqueness rule: a SELECT that checks no row already holds the value just before the INSERT, so two concurrent requests can both pass it; only a unique index on the column stops the second row. alone cannot stop the duplicate; only a unique index in the database can. This run reproduces the
race, then shows the same pair with no index:
# The uniqueness validation is a SELECT that runs just before the INSERT, so
# two requests that arrive together can both pass it. Only a unique index in
# the database can refuse the second INSERT. This shows both halves.
#
# To keep the run repeatable, the two requests validate first, in their own
# threads, against the empty table (both pass), then each runs its INSERT.
# That is the order a real race produces: every request checked, found nothing,
# and went on to insert.
require "active_record"
DB = "uniqueness_race.sqlite3"
File.delete(DB) if File.exist?(DB)
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: DB, pool: 5)
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :enrollments do |t|
t.integer :student_id, null: false
t.integer :batch_id, null: false
t.timestamps
end
# The index that makes the duplicate impossible, whatever the timing.
add_index :enrollments, [:student_id, :batch_id], unique: true, name: "idx_one_seat"
end
class Enrollment < ActiveRecord::Base
validates :student_id, uniqueness: { scope: :batch_id, message: "is already enrolled in this batch" }
end
puts "== Two requests, same student (41) and batch (7), validate at once =="
pair = %w[request-A request-B].map do |label|
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
e = Enrollment.new(student_id: 41, batch_id: 7)
[label, e, e.valid?] # the uniqueness SELECT finds no row yet
end
end
end.map(&:value)
pair.each { |label, _e, valid| puts " #{label}: valid? -> #{valid} (the SELECT found no existing seat)" }
puts "\n== Each request now runs its INSERT (validation already passed) =="
pair.each do |label, e, _valid|
e.save!(validate: false)
puts " #{label}: INSERT ok, id=#{e.id}"
rescue ActiveRecord::RecordNotUnique => error
first = error.message.split("\n").first
puts " #{label}: INSERT refused by the index -> #{first}"
end
puts " rows in enrollments: #{Enrollment.count} (the index kept it at one)"
puts "\n== Without the index, the validation alone lets the duplicate through =="
ActiveRecord::Schema.define do
create_table :wishes do |t|
t.integer :student_id, null: false
t.integer :batch_id, null: false
end
end
class Wish < ActiveRecord::Base
validates :student_id, uniqueness: { scope: :batch_id }
end
two = %w[request-A request-B].map do |label|
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
w = Wish.new(student_id: 41, batch_id: 7)
[label, w, w.valid?]
end
end
end.map(&:value)
two.each { |_label, w, _valid| w.save!(validate: false) }
puts " both validations passed: #{two.map { |_l, _w, v| v }.inspect}; rows in wishes: #{Wish.count} (a duplicate)"
ActiveRecord::Base.connection_pool.disconnect!
File.delete(DB) if File.exist?(DB) Output
== Two requests, same student (41) and batch (7), validate at once == request-A: valid? -> true (the SELECT found no existing seat) request-B: valid? -> true (the SELECT found no existing seat) == Each request now runs its INSERT (validation already passed) == request-A: INSERT ok, id=1 request-B: INSERT refused by the index -> SQLite3::ConstraintException: UNIQUE constraint failed: enrollments.student_id, enrollments.batch_id rows in enrollments: 1 (the index kept it at one) == Without the index, the validation alone lets the duplicate through == both validations passed: [true, true]; rows in wishes: 2 (a duplicate)
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby uniqueness_race.rb
Both requests printed valid? -> true, because each SELECT ran against the empty table. Then the first INSERT
succeeded and the second was refused by the index, which Active Record raised as ActiveRecord::RecordNotUnique; the
row count stayed at one. The second table had the validation but no index, and there both inserts went through, leaving
a duplicate. Step through where the race opens:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A checks the seat. Request A's uniqueness validation runs its SELECT: is student 41 already enrolled in batch 7? The table has no such row yet.
- A passes. The SELECT finds nothing, so Request A passes the uniqueness validation. It has not inserted anything yet.
- B checks the same seat. Request B arrives in the same moment and runs the same SELECT. Request A has still not inserted, so the table is empty for B too.
- B passes as well. Request B also finds no row and passes the validation. Both requests now believe the seat is open, which is the race the SELECT cannot avoid.
- A inserts the row. Request A runs its INSERT and the row is written. One seat is taken.
- The index refuses B. Request B runs its INSERT, but the unique index already holds the pair 41 and 7, so the database refuses it and Active Record raises RecordNotUnique. The count stays at one.
So you keep both. The validation gives the ordinary user a clean message almost every time; the unique index closes the
narrow window the validation cannot. Add the index in a migration, and rescue ActiveRecord::RecordNotUnique around
the save turns the rare collision into the same friendly error. The validation is the message; the index is the
guarantee.
Trusting the uniqueness validation alone
Without a unique index, two near-simultaneous requests both pass the SELECT and both INSERT, as the recorded run
shows. The validation is not a lock. Add the unique index in a migration and rescue RecordNotUnique.
Checkpoint
Check: Uniqueness is a check before the insert
Custom rules and rules that only sometimes apply
When Rails has no built-in rule, write your own. A method named in validate :method_name runs on every validation and
calls errors.add when something is wrong; an ActiveModel::Validator class does the same job in a reusable form. And
if: or unless: keeps a rule from running when it does not apply. This Guardian uses all three, plus an
allowlist: A rule that accepts only values on a known-good list and refuses everything else, rather than trying to list the values to reject, which always misses one. for the relation:
# Two ways to write a rule Rails has no built-in for, and how if:/unless: keep
# a rule from running when it should not apply. Standalone Active Record.
require "active_record"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :guardians do |t|
t.string :name
t.string :relation
t.string :pin_code
t.boolean :primary_contact, default: false
t.string :phone
end
end
# An allowlist: only these relations are accepted. Anything else is rejected,
# which is safer than trying to list every value to reject.
RELATIONS = %w[mother father guardian grandparent sibling].freeze
# A reusable validator class: Rails calls validate for each record.
class PinCodeValidator < ActiveModel::Validator
def validate(record)
pin = record.pin_code.to_s
return if pin.match?(/\A[1-9]\d{5}\z/)
record.errors.add(:pin_code, :invalid, message: "must be 6 digits and not start with 0")
end
end
class Guardian < ActiveRecord::Base
validates_with PinCodeValidator
validates :relation, inclusion: { in: RELATIONS, message: "is not a relation we accept" }
# A method validator: a primary contact must have a phone, others need not.
validate :primary_contact_has_phone
# A conditional rule: only check the phone shape when one was given.
validates :phone,
format: { with: /\A\+91[6-9]\d{9}\z/, message: "must be an Indian mobile" },
unless: -> { phone.blank? }
def primary_contact_has_phone
return unless primary_contact?
errors.add(:phone, :blank, message: "is needed for the primary contact") if phone.blank?
end
end
def report(label, g)
puts "#{label}: valid? -> #{g.valid?}"
g.errors.full_messages.each { |m| puts " - #{m}" }
end
puts "== The allowlist rejects a relation it does not know =="
report("relation 'uncle'", Guardian.new(name: "Raj", relation: "uncle", pin_code: "560001"))
puts "\n== The validator class checks the PIN code =="
report("pin_code '0560'", Guardian.new(name: "Raj", relation: "father", pin_code: "0560"))
puts "\n== unless: skips the phone rule when no phone was given =="
report("no phone, not primary", Guardian.new(name: "Raj", relation: "father", pin_code: "560001"))
puts "\n== but a primary contact must have one =="
report("primary, no phone", Guardian.new(name: "Raj", relation: "father", pin_code: "560001", primary_contact: true))
puts "\n== a well-formed guardian passes every rule =="
report("all good", Guardian.new(name: "Sita", relation: "mother", pin_code: "560001", primary_contact: true, phone: "+919876543210")) Output
== The allowlist rejects a relation it does not know == relation 'uncle': valid? -> false - Relation is not a relation we accept == The validator class checks the PIN code == pin_code '0560': valid? -> false - Pin code must be 6 digits and not start with 0 == unless: skips the phone rule when no phone was given == no phone, not primary: valid? -> true == but a primary contact must have one == primary, no phone: valid? -> false - Phone is needed for the primary contact == a well-formed guardian passes every rule == all good: valid? -> true
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby custom_validator.rb
The relation must be one of five values; uncle was refused with Relation is not a relation we accept. The
PinCodeValidator class rejected 0560 with its own message. The phone rule carried unless: -> { phone.blank? }, so
a guardian with no phone passed, while a guardian marked as the primary contact failed the method rule
Phone is needed for the primary contact. The last guardian, well formed, passed every rule.
The allowlist is the safer shape, and the one OWASP’s Input Validation Cheat Sheet recommends: accept only values on a known-good list rather than trying to list every bad value, because you will miss one. Check type and length at the point input enters the app, and keep each message something the user can act on.
Checkpoint
Check: Custom and conditional rules
An enum can report a bad value instead of raising
An enum maps a column to a set of named values and gives you predicates such as lead.contacted?. By default,
assigning a value that is not in the set raises ArgumentError on the spot, which is awkward when the value came from a
form: the request blows up instead of showing a message. enum :stage, STAGES, validate: true changes that: an unknown
value is held, and valid? reports it as an ordinary validation error.
# enum with validate: true turns an unknown value into a validation error you
# can show, instead of the ArgumentError a plain enum raises on assignment.
# Standalone Active Record, in-memory SQLite, no server.
require "active_record"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
create_table :enquiries do |t|
t.string :name
t.string :stage, default: "open"
end
create_table :leads do |t|
t.string :name
t.string :stage, default: "open"
end
end
STAGES = { open: "open", contacted: "contacted", joined: "joined", lost: "lost" }.freeze
class Enquiry < ActiveRecord::Base
# A plain enum: an unknown value raises on assignment.
enum :stage, STAGES
end
class Lead < ActiveRecord::Base
# validate: true: an unknown value becomes a normal validation error.
enum :stage, STAGES, validate: true
end
puts "== A plain enum raises on an unknown value =="
begin
Enquiry.new(stage: "prospect")
rescue ArgumentError => e
puts " Enquiry.new(stage: \"prospect\") raised ArgumentError"
puts " -> #{e.message}"
end
puts "\n== With validate: true the unknown value is held and reported =="
lead = Lead.new(name: "Meena", stage: "prospect")
puts " Lead.new(stage: \"prospect\") did not raise"
puts " lead.stage -> #{lead.stage.inspect}"
puts " lead.valid? -> #{lead.valid?}"
lead.errors.full_messages.each { |m| puts " - #{m}" }
puts "\n== A known value still works, and the predicates come with it =="
good = Lead.new(name: "Arjun", stage: "contacted")
puts " good.valid? -> #{good.valid?}"
puts " good.contacted? -> #{good.contacted?}"
puts " Lead.stages -> #{Lead.stages.inspect}" Output
== A plain enum raises on an unknown value ==
Enquiry.new(stage: "prospect") raised ArgumentError
-> 'prospect' is not a valid stage
== With validate: true the unknown value is held and reported ==
Lead.new(stage: "prospect") did not raise
lead.stage -> "prospect"
lead.valid? -> false
- Stage is not included in the list
== A known value still works, and the predicates come with it ==
good.valid? -> true
good.contacted? -> true
Lead.stages -> {"open" => "open", "contacted" => "contacted", "joined" => "joined", "lost" => "lost"}
Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby enum_validate.rb
The plain enum raised ArgumentError: 'prospect' is not a valid stage. The enum with validate: true did not raise;
lead.stage kept "prospect", valid? returned false, and the message was Stage is not included in the list. A
known value such as contacted still worked, predicates and all. So when an enum column is filled from user input,
validate: true turns a crash into a message you can show, the same way every other rule does.
validate: true for form-filled enums
Reach for validate: true whenever an enum column is set from a form or an API. For an enum set only in code, where
a wrong value is a bug, the default ArgumentError is the louder, better signal.
Checkpoint
Check: Reporting a bad enum value
Practise: rules, errors and normalization
Kaksha takes sign-ups from a web form, so its input is untidy. In a copy of phone_normalization.rb, which already
has a guardians table, add a students table with name, mobile and grade, then write these rules and run the
file after each one:
- A
normalize_mobilemethod that turns+91 98765-43210,098765 43210,919876543210and9876543210into+919876543210: keep the digits, drop a leading0, drop a leading91from a twelve-digit number, then write+91and the ten digits that remain. - A Student that trims its
nameand collapses runs of spaces, normalizesmobilewith that method, and requires a name of at most 80 characters, a mobile of the shape+91, a digit from 6 to 9 and nine more digits, and agradethat is a whole number from 1 to 12. - A Guardian with the same name and mobile rules, a six-digit
pin_codethat does not start with 0 (keep only its digits first) and arelationtaken from an allowlist such as mother, father and grandparent. - Print
errors.full_messagesfor a blank record, a mobile typed four ways, grade 13, an unknown relation and a PIN that starts with 0, and check that each message names the rule you meant.
Most of the questions below use this lesson’s runs: the blank student and its messages, the context that skips a rule, the phone stored four ways, the race the index stopped, the allowlist and the two enums. Some move a rule to a table the lesson never built. Every option explains what it gets right, or what it overlooks.
Practice that adapts to you
Practise this lesson
9 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: a uniqueness validation is on the model, yet a duplicate row appeared in production. How?
Because the validation is a SELECT that runs just before the INSERT, not a lock. Two requests that enrol the same
student in the same batch at the same moment both run that SELECT while the table has no such row, both pass, and
both INSERT. The recorded run shows exactly this: both requests printed valid? -> true, and without a unique index
both rows were written. The fix is a unique index in a migration, which the database enforces whatever the timing, plus
a rescue ActiveRecord::RecordNotUnique around the save so the rare collision still shows the user a clean message. You
keep the validation for the message in the common case and the index for correctness under the race.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Enrollments in production have a uniqueness validation, yet duplicates exist. You must stop new duplicates and add a unique index to a table that already has some. Walk through diagnosing it, adding the index safely and keeping the user's message clean.
-
Fresher to mid
A teammate wants to "fix" every bad input by normalizing it, so forms never show errors. Argue what belongs in normalization, what belongs in validation, and what should simply be refused, with examples from phone numbers, names and PIN codes.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
A validation fails during save. What happens, and where do you read the result?
Nothing is written and save returns false; no exception is raised. errors.full_messages gives the sentences for the user, errors.details the symbols for translation.
-
How can one model hold the light checks of a draft and the stricter checks of a finished record?
With contexts: on: :create, on: :update or a custom context name decides which rules run for a given save or valid? call.
-
When does a normalizes rule run, and does it replace validation?
It runs when the value is assigned and when the attribute is used in a query, so values are stored and searched in one shape. Validations still run afterwards, on the cleaned value.
-
Why can two rows with the same value appear despite a uniqueness validation?
The validation is a SELECT just before the INSERT, not a lock. Two requests at the same moment may each see no such row and each insert one. A unique index stops the second row; rescue ActiveRecord::RecordNotUnique to show a clean message.
-
How do you add a rule that Rails has no built-in validator for, or one that applies only sometimes?
Write it with validate :method_name or an ActiveModel::Validator class, and gate it with if: or unless:. Accept values from a known-good list rather than trying to list the bad ones.
-
An enum attribute is set from user input. How do you get an error message instead of an exception for an unknown value?
Declare the enum with validate: true; an unknown value then becomes a validation error on the record.
Key takeaways
- A validation adds a message to
errorsand makessavereturnfalserather than raising, so you readerrors.full_messagesfor the user anderrors.detailsfor the i18n symbol. on: :createand custom contexts decide which rules run, so one model can hold the checks a draft needs and the stricter checks a finished record needs.normalizescleans a value on assignment and on a query of that attribute, so it is stored and searched for in one shape, while validation still checks the cleaned value.- A uniqueness validation is a
SELECTbefore theINSERTand can lose a race, so back it with a unique index and rescueRecordNotUnique. - Write rules Rails lacks with
validate :methodor anActiveModel::Validator, prefer an allowlist to a denylist, gate rules withif:/unless:, and useenum … validate: truefor enums filled from input.
Terms in this lesson
- allowlist
- A rule that accepts only values on a known-good list and refuses everything else, rather than trying to list the values to reject, which always misses one.
- normalization
- Cleaning an attribute into one canonical shape with normalizes, which runs when the value is assigned and also when it is used to query that attribute.
- uniqueness validation
- The uniqueness rule: a SELECT that checks no row already holds the value just before the INSERT, so two concurrent requests can both pass it; only a unique index on the column stops the second row.
- validation
- A rule Active Record checks before it saves a record; a rule that fails adds a message to the record's errors and makes save return false instead of writing the row.
References
- Active Record Validations (Ruby on Rails)
- ActiveModel::Attributes::Normalization::ClassMethods (Ruby on Rails)
- ActiveRecord::Enum (Ruby on Rails)
- Input Validation Cheat Sheet (OWASP)
- Rails Internationalization (I18n) API (Ruby on Rails)
- active_model/error.rb (Rails 8.1.4 source) (Ruby on Rails)
Report a problem with this lesson
Kept only in this browser. Your Learn progress