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.
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
- Reading the errors object
- Adding your own errors
- Validation errors versus database errors
- Surfacing errors in controllers and APIs
- How this fits the rest of the stack
- FAQ
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:
- rails s: What the Development Server Does and Why Production Is Different
- Ruby on Rails Services: Web, Worker, Database, and the Parts People Forget
- Rails API: The API-Only Mode, the Serializers, the Auth Pattern, and When Rails Beats the Node Frameworks
- Services on RunxBuild
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.