Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild
Back to Blog Troubleshooting

Rails Errors: Reading ActiveModel::Errors Without Guessing

Sean

Platform Writer

Aug 14, 2026
8 min read

When save returns false in Rails, the reason is in the errors object on the model, not in an exception. Read it with record.errors.full_messages for something human, and record.errors.details for something a machine can branch on. The API changed meaningfully in Rails 6.1, which is why half the advice you find online no longer matches what your console prints.

Rails Errors: Reading ActiveModel::Errors Without Guessing

Errors in Rails span three separate mechanisms that get conflated: validation failures which are data, exceptions which are control flow, and the rescue behaviour that turns both into HTTP responses. Being clear about which one you are looking at is most of the debugging.

Table of contents

Why save returned false and nothing was raised

This is the first thing to internalise. Rails has two families of persistence methods, and they fail in different ways.

user = User.new(email: "")

user.save          # => false. No exception. Errors are on the object.
user.save!         # => raises ActiveRecord::RecordInvalid

user.valid?        # => false, and populates user.errors
user.errors.any?   # => true

The non-bang methods treat invalid data as an expected outcome, because a user submitting a bad form is not exceptional. The bang methods treat it as exceptional, which is what you want in a background job or a script where nobody is watching a return value.

The bug this produces is a controller that calls save, ignores the return value, and redirects with a success message while nothing was written. It is silent, and it is common.

Important detail: errors is only populated after a validation run. Calling user.errors on a freshly built object returns empty, because nothing has been validated yet. Call valid? or save first.

Reading the errors object

Since Rails 6.1, errors is a collection of ActiveModel::Error objects rather than a hash of arrays. The old hash-like access still works through a deprecation shim in some versions, which is why examples disagree.

user.errors.full_messages
# => ["Email can't be blank", "Password is too short (minimum is 8 characters)"]

user.errors[:email]
# => ["can't be blank"]

user.errors.full_messages_for(:email)
# => ["Email can't be blank"]

user.errors.details
# => { email: [{ error: :blank }],
#      password: [{ error: :too_short, count: 8 }] }

user.errors.where(:email, :blank).any?   # => true
user.errors.include?(:email)             # => true
user.errors.count                        # => 2

The distinction that matters: full_messages is for humans and is translated, so never branch on its contents. details returns symbols, which are stable across locales and Rails versions, and is what you should test against.

Code that checks whether an error message string includes “blank” breaks the moment the application is translated, or when the message is customised. Use errors.where(:email, :blank) instead.

# Fragile.
if user.errors[:email].include?("can't be blank")

# Correct.
if user.errors.where(:email, :blank).any?

Adding your own errors

class User < ApplicationRecord
  validate :email_domain_allowed

  private

  def email_domain_allowed
    return if email.blank?

    domain = email.split("@").last
    unless AllowedDomain.exists?(name: domain)
      errors.add(:email, :domain_not_allowed,
                 message: "domain %{domain} is not permitted",
                 domain: domain)
    end
  end
end

Pass a symbol as the error type as well as a message. The symbol is what details reports and what your code and tests can rely on; the message is what the user reads. Adding only a string leaves details with a generic :invalid and nothing specific to match against.

For an error that belongs to the record rather than a field, use :base.

errors.add(:base, :insufficient_funds, message: "Balance is too low")

One thing that catches people: returning false from a validation method does nothing. A record is invalid because an error was added, not because a method returned falsy. In old Rails a false return from a before_save callback halted the chain; that behaviour was removed and the modern equivalent is throw :abort.

Validation errors versus database errors

Validations run in Ruby, before the query. The database has its own constraints, and they fire in a completely different way.

# Validation failure: returns false, errors populated.
user.save    # => false

# Constraint violation: raises, errors NOT populated.
begin
  user.save!
rescue ActiveRecord::RecordNotUnique => e
  # A unique index rejected it. The validation did not catch this.
end

The classic case is uniqueness. A validates_uniqueness_of check runs a SELECT and then an INSERT, and two concurrent requests can both pass the SELECT before either inserts. Without a unique index in the database, you get duplicate rows. With one, the second insert raises RecordNotUnique rather than failing validation.

So the correct arrangement is both: the validation for a good error message in the normal case, and the database index for correctness under concurrency. Handling only one leaves either bad data or an ugly 500.

  • RecordInvalid: validations failed, from a bang method.
  • RecordNotUnique: a unique index rejected the row.
  • InvalidForeignKey: a foreign key constraint rejected it.
  • NotNullViolation: a NOT NULL column received nil, meaning a presence validation was missing.
  • RecordNotFound: find could not locate the record, which Rails renders as a 404.

Surfacing errors in controllers and APIs

class UsersController < ApplicationController
  def create
    @user = User.new(user_params)

    if @user.save
      redirect_to @user, notice: "Created"
    else
      render :new, status: :unprocessable_entity
    end
  end
end

The status matters. Rendering a failed form with a 200 tells every client that the request succeeded, which breaks Turbo, breaks API consumers, and misleads monitoring. Validation failures are 422.

For a JSON API, return the machine-readable form rather than sentences.

render json: {
  errors: @user.errors.map do |error|
    { field: error.attribute, code: error.type, message: error.full_message }
  end
}, status: :unprocessable_entity

That gives the client a field to highlight, a code to branch on, and a message to display, without forcing it to parse English. It is a small amount of extra work and it removes an entire category of brittle client code.

How this fits the rest of the stack

The errors worth catching in production are rarely the validation ones, since those are visible in the response. The expensive ones are the constraint violations and timeouts that only appear under real concurrency, and finding those means having the failing request and the failing deploy in the same place. A Ruby application deployed from a repository on RunxBuild gets build logs and runtime logs per deploy, plus a rollback when a release goes wrong. The RunxBuild hosting calculator shows the service and its managed Postgres or MySQL instance as separate line items.

Useful related references:

FAQ

Why does save return false without an error in Rails?

Validation failure is an expected outcome for the non-bang methods, so they return false and record the reasons on the model. Read them with record.errors.full_messages, or use save! to raise instead.

What is the difference between errors.messages and errors.details?

messages and full_messages return translated strings for display. details returns symbols such as :blank or :too_short, which are stable across locales and versions and are what your code and tests should branch on.

How do I add a custom validation error in Rails?

Call errors.add(:attribute, :error_symbol, message: ”…”) inside a method registered with validate. Always include the symbol, since that is what details reports and what tests can match reliably.

Why is my uniqueness validation letting duplicates through?

It performs a SELECT then an INSERT, so two concurrent requests can both pass before either writes. Add a unique index in the database as well, and handle ActiveRecord::RecordNotUnique.

What HTTP status should a failed Rails form return?

422 Unprocessable Entity, rendered with status: :unprocessable_entity. Returning 200 tells clients and monitoring that the request succeeded and breaks Turbo and API consumers.

#Rails Errors#ActiveModel#Validations#Ruby on Rails#ActiveRecord