Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 15 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Each rule fills errors; save returns false and writes no row Ruby · validation_errors.rb
# 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:

Interactive · stage by stage What save does with your rules Step through one call to save. Watch where the errors object is filled, and the two ways the call can end.
What save does with your rules, step 5 of 5. Showing save is called (returned false), Every rule runs, errors is filled (Name can't be blank, …), Write the row (row saved). In focus: Write the row.any error: returns falsesave is calledreturned falseEvery rule runspresenceerrors is filledName can't be blank, …Write the rowrow savederrors empty

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 A student fails a validation. What does bad.save return, and what reaches the table?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It returns false, and no row is written

    save returns false on a failed validation and writes nothing; the messages wait in errors for the caller.

  2. Question 2 of 2 Why read errors.details[:grade] rather than only errors.full_messages?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: details carries the error symbol, such as :in, that the i18n layer turns into a message

    full_messages is for the user; details gives the symbol and values an app needs to translate or branch on.

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

  1. Question 1 of 2 A rule is declared with on: :create. An existing student is updated. Does that rule run?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No on an update; yes under valid?(:create)

    on: :create runs the rule only in the create context, so an ordinary update is not refused for it.

  2. Question 2 of 2 In errors.add(:guardian_name, :blank, message: "..."), what is the :blank for?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It is the i18n key; message is the fallback text

    Passing a symbol lets the same rule produce a translated message through i18n, with message as the fallback.

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:

One number typed four ways, stored once, found three ways Ruby · phone_normalization.rb
# 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:

Interactive · stage by stage One cleaner, on the way in and on the way to a query Step through a phone being stored, then found by a differently typed value. The same block runs both times.
One cleaner, on the way in and on the way to a query, step 6 of 6. Showing On assignment, normalizes runs (keeps 9876543210), Column holds one shape (found), A later find_by. In focus: Column holds one shape.On assignment+91 98765-43210normalizes runskeeps 9876543210Column holds one shapefoundA later find_by98765 43210matches

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. A lookup uses another shape. Later someone looks the guardian up with find_by(phone: "98765 43210"), a value typed yet another way.
  5. 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.
  6. 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

  1. Question 1 of 2 A phone is stored as +919876543210. Why does find_by(phone: "98765 43210") still find it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Active Record runs the same normalizes block on the query value before the WHERE

    normalizes runs on a value used to query that attribute, so the cleaned query equals the stored value.

  2. Question 2 of 2 The run normalized 011 2345 6789 to +911123456789 and then marked it invalid. What does that show?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Normalizing and validating are two steps; a tidy value can still be wrong

    normalizes tidies the value; validation still checks it, so you keep both.

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:

Both requests pass the validation; the unique index refuses the second insert Ruby · uniqueness_race.rb
# 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:

Interactive · message by message Two requests, one seat, one unique index Follow both requests. Watch each SELECT find nothing, then watch the index refuse the second INSERT.
Two requests, one seat, one unique index, step 6 of 6. Messages so far: Request A to Database: SELECT 41; Database to Request A: no row; Request B to Database: SELECT 41; Database to Request B: no row; Request A to Database: INSERT; Request B to Database: INSERT.Request Avalid? trueRequest Bvalid? trueDatabaseRecordNotUniqueSELECT 41no rowSELECT 41no rowINSERTINSERT

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. A passes. The SELECT finds nothing, so Request A passes the uniqueness validation. It has not inserted anything yet.
  3. 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.
  4. 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.
  5. A inserts the row. Request A runs its INSERT and the row is written. One seat is taken.
  6. 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

  1. Question 1 of 2 Two requests enrol the same student in the same batch at once. What did both print for valid??

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Both printed true, because each SELECT ran against the empty table

    The uniqueness validation is a SELECT before the INSERT, so concurrent requests can both pass it.

  2. Question 2 of 2 What stopped the duplicate enrollment that the validation let through?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A unique index in the database, which raised RecordNotUnique on the second INSERT

    Only the unique index enforces uniqueness under a race; keep it and rescue RecordNotUnique.

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:

A validator class, a method validator, an allowlist and a conditional rule Ruby · custom_validator.rb
# 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

  1. Question 1 of 2 Why accept a guardian relation from an allowlist of five values rather than rejecting a list of bad ones?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A denylist will always miss a value; an allowlist accepts only what you know is good

    An allowlist admits only known-good values, so a value you never thought of is refused by default.

  2. Question 2 of 2 The phone rule carried unless: -> { phone.blank? }. What happened for a guardian with no phone?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The format rule was skipped, so the guardian was valid

    if: and unless: decide whether a rule runs, so an optional value is only checked when it is present.

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.

A plain enum raises; validate: true reports the bad value instead Ruby · enum_validate.rb
# 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

  1. Question 1 of 2 A stage column is filled from a form. Why add validate: true to its enum?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An unknown value becomes a validation error instead of raising ArgumentError

    validate: true holds an unknown value and reports it like any other rule, so a form does not crash the request.

  2. Question 2 of 2 A plain enum (no validate: true) is assigned a value not in the set. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It raises ArgumentError on assignment

    A plain enum raises ArgumentError on an unknown value, which is loud and right for a value set only in code.

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:

  1. A normalize_mobile method that turns +91 98765-43210, 098765 43210, 919876543210 and 9876543210 into +919876543210: keep the digits, drop a leading 0, drop a leading 91 from a twelve-digit number, then write +91 and the ten digits that remain.
  2. A Student that trims its name and collapses runs of spaces, normalizes mobile with 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 a grade that is a whole number from 1 to 12.
  3. A Guardian with the same name and mobile rules, a six-digit pin_code that does not start with 0 (keep only its digits first) and a relation taken from an allowlist such as mother, father and grandparent.
  4. Print errors.full_messages for 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.

  1. Question 1 of 9 Which call raises when a record is invalid, rather than returning false?

    Choose one answer.

    Show the answer: Question 1 of 9

    Answer: save!

    save and valid? return booleans; save! and create! raise RecordInvalid, which suits a step that must not fail.

  2. Question 2 of 9 A form needs to highlight only the mobile field and show its message. Which reads just that?

    Choose one answer.

    Show the answer: Question 2 of 9

    Answer: record.errors[:mobile]

    errors[:attribute] gives one field''s messages; full_messages prefixes the human attribute name to each.

  3. Question 3 of 9 You want a strict check only when a student is actually enrolled, not while a record is a draft. What do you use?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: A custom context, such as on: :enrolment, and valid?(:enrolment) at that step

    Custom contexts let one model carry light checks for a draft and stricter checks for a finished record.

  4. Question 4 of 9 You normalize an email with ->(v) { v.strip.downcase }. What does find_by(email: " Asha@Kaksha.IN ") do?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: It searches for asha@kaksha.in, because the query value is normalized too

    A normalizer applies to query values as well, so a lookup matches the stored, cleaned form.

  5. Question 5 of 9 Rows saved before you added normalizes hold phones in mixed shapes. What is true?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: Old rows keep their old shape until re-saved; a data migration can normalize them

    normalizes affects assignment and queries going forward; existing rows need a one-off data migration to match.

  6. Question 6 of 9 What does scope: :batch_id add to uniqueness: on student_id?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: It allows a student in many batches, but only once per batch

    scope makes the pair unique, so the same student may appear once in each batch, matching the unique index.

  7. Question 7 of 9 You added the unique index and rescue RecordNotUnique. A collision now happens once a week. What does the user see?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: The same friendly message, because the rescue turns the raised error into a validation error

    The index enforces correctness and the rescue restores the clean message, so the rare race looks ordinary.

  8. Question 8 of 9 You want the PIN-code rule reused on three models. Which fits best?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: An ActiveModel::Validator class used with validates_with

    A validator class carries one rule you attach to many models with validates_with.

  9. Question 9 of 9 An enum column is set only by internal code, never by a form. Which is the better default?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: A plain enum, so a wrong value raises ArgumentError at once

    Use validate: true for form-filled enums and the plain enum where a bad value means a bug in your own code.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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 errors and makes save return false rather than raising, so you read errors.full_messages for the user and errors.details for the i18n symbol.
  • on: :create and custom contexts decide which rules run, so one model can hold the checks a draft needs and the stricter checks a finished record needs.
  • normalizes cleans 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 SELECT before the INSERT and can lose a race, so back it with a unique index and rescue RecordNotUnique.
  • Write rules Rails lacks with validate :method or an ActiveModel::Validator, prefer an allowlist to a denylist, gate rules with if:/unless:, and use enum … validate: true for 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.