Database Constraints Vs. Rails Validations
Learn when to use Rails validations, database constraints, or both to protect data integrity, with migrations and tests.
By Jean Emmanuel Cadet • 28 min read
Every application is only as trustworthy as its data. If an order can exist without a customer, two accounts can share the same email address, or a product can have a negative price, every report, email, and invoice built on that data becomes suspect. Bad data tends to surface late, in places far from where it was created, and it is expensive to repair.
Rails gives you a friendly first line of defense: Active Record validations. You declare rules such as validates :email, presence: true in a model, and Rails checks them before saving. This works well and gives users clear feedback. But validations run in Ruby, inside your Rails process, and they are only one of the ways data reaches your tables.
That is the core of the Database Constraints vs. Rails Validations question. Constraints such as NOT NULL, UNIQUE, FOREIGN KEY, and CHECK are enforced by the database itself, no matter which code path performs the write. They are less friendly to users, but they are much harder to bypass.
So should you use Rails validations, database constraints, or both? In most production applications the answer is both, and each layer has a different job. This guide explains how each one works, where each falls short, how to add constraints safely with Rails 8.1 migrations, and how to test both layers. The examples use SQLite where possible, with PostgreSQL differences called out where they matter.
What Are Rails Validations?
Active Record validations are rules declared on a model that Rails evaluates before it writes a record to the database. If any rule fails, the record is considered invalid and Rails does not run the INSERT or UPDATE. Instead, it collects the problems in the record’s errors object so you can show them to the user.
The important point is where validations live: in your application code. They know nothing about the database’s own rules, and the database knows nothing about them.
Common validation helpers
Here is a small model that uses several of the built-in helpers:
class User < ApplicationRecord
validates :name, presence: true, length: { maximum: 80 }
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
end
presence rejects nil and blank strings. length limits the size of a value. format checks a value against a regular expression, and URI::MailTo::EMAIL_REGEXP is a practical, built-in pattern for email addresses.
A few more helpers cover other common needs:
class Product < ApplicationRecord
validates :sku, uniqueness: true
validates :price_cents, numericality: { only_integer: true, greater_than: 0 }
validates :quantity, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :category, inclusion: { in: %w[book course template] }
validates :slug, exclusion: { in: %w[new edit admin], message: "%{value} is reserved" }
end
uniquenesschecks that no other record has the same value.numericalitychecks that a value is a number and can enforce ranges, integers only, and so on.inclusionallows only values from a given list.exclusionblocks values from a given list.
Custom validations
When a built-in helper is not enough, write a custom validation method with validate:
class Event < ApplicationRecord
validate :ends_after_it_starts
private
def ends_after_it_starts
return if starts_at.blank? || ends_at.blank?
errors.add(:ends_at, "must be after the start time") if ends_at <= starts_at
end
end
This is the right tool for rules that depend on several attributes. If you reuse the same rule across models, extract it into a validator class. Our guide to Rails custom validators walks through that approach.
Reading validation errors
Calling valid? runs the validations and populates errors:
user = User.new(name: "", email: "")
user.valid?
# => false
user.errors.full_messages
# => ["Name can't be blank", "Email can't be blank", "Email is invalid"]
user.errors[:email]
# => ["can't be blank", "is invalid"]
errors.full_messages returns readable sentences that include the attribute name, which is handy for a summary at the top of a form. errors[:email] returns only the messages for one attribute, which is what you usually want next to a specific input.
save vs. save!
When validations fail, save and save! behave differently:
user = User.new(name: "", email: "")
user.save # => false (nothing is written, errors are populated)
user.save! # => raises ActiveRecord::RecordInvalid
Use save in controllers where you want to re-render a form on failure. Use save! (or create!) in scripts, jobs, and seeds, where an invalid record is a bug that should stop execution loudly.
Validation contexts
By default, validations run on both create and update. You can limit a validation to a specific moment with on::
class User < ApplicationRecord
validates :password, presence: true, on: :create
validates :phone_number, presence: true, on: :checkout
end
:create and :update are built in. :checkout is a custom context that you trigger explicitly:
user.valid?(:checkout)
user.save(context: :checkout)
Contexts are useful for multi-step flows, such as an onboarding wizard where a field is only required at a later step. Validations declared without on: still run when you use a custom context.
What Are Database Constraints?
A database constraint is a rule attached to a table or column that the database engine enforces on every write. If an INSERT or UPDATE would break the rule, the database rejects the statement and returns an error. It does not matter who sent the statement.
That last point is what makes constraints so valuable. Your data may be written by:
- Rails controllers and models
- Background jobs and rake tasks
- A raw SQL script run in a console
- A data migration
- Another service or application that shares the database
Only some of these go through your Active Record models. Constraints apply to all of them.
The common constraints
- PRIMARY KEY uniquely identifies each row and implies
NOT NULL. Rails adds one to every table by default. - NOT NULL forbids missing values in a column.
- UNIQUE forbids duplicate values in a column or combination of columns.
- FOREIGN KEY requires that a value in one table matches an existing row in another table.
- CHECK requires that a boolean expression about the row’s own values is true.
Simple SQL examples
Here is a SQLite-compatible schema that uses all five:
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL CHECK (price_cents > 0)
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id)
);
Now try writes that break the rules:
INSERT INTO users (name, email) VALUES (NULL, '[email protected]');
-- NOT NULL constraint failed: users.name
INSERT INTO users (name, email) VALUES ('Ada', '[email protected]');
INSERT INTO users (name, email) VALUES ('Ada Two', '[email protected]');
-- UNIQUE constraint failed: users.email
INSERT INTO products (name, price_cents) VALUES ('Course', 0);
-- CHECK constraint failed: price_cents > 0
INSERT INTO orders (user_id) VALUES (999);
-- FOREIGN KEY constraint failed
The exact wording of these errors differs between SQLite, PostgreSQL, and MySQL, but the behavior is the same: the write is refused. One SQLite detail worth knowing is that foreign key enforcement must be switched on per connection with PRAGMA foreign_keys = ON. The Rails SQLite adapter does this for you, but a raw sqlite3 shell session may not.
Constraints are not a user interface
Constraints are a safety net, not a user experience. A database error like UNIQUE constraint failed: users.email is meant for developers. Nobody wants to show it on a signup form. You still need application-level validation to give people clear, friendly feedback before the database ever gets involved.
Rails Validations vs. Database Constraints
Both approaches protect data, but they operate at different layers and serve different purposes.
Rails validations run in your Ruby process, before the SQL statement is sent. They are flexible, can call any Ruby code, and produce messages designed for people. Database constraints run inside the database engine while it executes the statement. They are limited to what the database can express, but they are enforced for every writer and cannot be skipped by accident.
Ways validations get skipped
Plenty of Active Record methods deliberately skip validations:
user.save(validate: false)
user.update_attribute(:email, nil) # skips validations
user.update_column(:email, nil) # skips validations and callbacks
user.update_columns(email: nil)
User.where(active: false).update_all(email: nil)
User.insert_all([{ name: "Ada", email: nil }])
User.upsert_all([{ id: 1, email: nil }])
These methods exist for good reasons, such as performance in bulk operations. But each of them can write data that a model validation would reject. And once you leave Rails entirely (a SQL console, a reporting tool, a second application), no Active Record validation runs at all.
Comparison
Rails validations | Database constraints | |
|---|---|---|
Execution layer | Application (Ruby, Active Record) | Database engine |
Main purpose | Validate input and give feedback | Guarantee stored data integrity |
Error handling |
| Database exceptions such as |
Protection against concurrent writes | None on their own (uniqueness is a check-then-write) | Yes, enforced atomically by the database |
Protection against external database writes | None | Yes |
User-facing error messages | Yes, customizable and translatable | No, you must translate errors yourself |
The database is the final authority for any rule it can express. Validations are what make the application pleasant to use, and constraints are what make the data safe to trust.
Why Rails Validations Alone Are Not Enough
The most common reason teams get bitten by this is the uniqueness validation, so it is worth understanding exactly why it fails under load.
The race condition
When you write validates :email, uniqueness: true, Rails runs a query before saving. Conceptually it looks like this:
SELECT 1 AS one FROM users WHERE email = '[email protected]' LIMIT 1;
-- no rows returned, so the validation passes
INSERT INTO users (email, ...) VALUES ('[email protected]', ...);
That is a check followed by a write, and there is a gap between them. Imagine two requests arriving at nearly the same moment:
Request A: SELECT ... WHERE email = '[email protected]' -> no rows, valid
Request B: SELECT ... WHERE email = '[email protected]' -> no rows, valid
Request A: INSERT INTO users (email) VALUES (...) -> succeeds
Request B: INSERT INTO users (email) VALUES (...) -> succeeds (duplicate!)
Both requests passed validation because neither record had been committed when the other checked. This is not a bug in Rails. It is a limitation of any application-level check-then-write approach. The Rails documentation itself warns about it, and the fix is a unique index.
It can happen with double-clicked submit buttons, retried API calls, multiple app servers, and background jobs that run in parallel.
Validations do not create indexes
Another common misunderstanding is that uniqueness: true changes the database. It does not. It only adds a Ruby-level check. Your schema is untouched, and the database has no idea that email should be unique.
Bypassing validations produces invalid data
The same gap exists for every skipped validation:
product = Product.new(name: "Course", price_cents: -500)
product.valid? # => false
product.save(validate: false) # writes the row anyway
If the products table has no CHECK constraint, that negative price is now permanent data. For any rule your business truly depends on, such as unique emails, required ownership, or positive prices, you need enforcement that cannot be skipped. That is what database constraints provide.
The Uniqueness Validation Problem
Let us fix the duplicate email problem properly.
Step 1: keep the validation
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true
end
This gives users a friendly message in the normal case: “Email has already been taken.”
Step 2: add a unique index
Create a migration:
bin/rails generate migration AddUniqueIndexToUsersEmail
class AddUniqueIndexToUsersEmail < ActiveRecord::Migration[8.1]
def change
add_index :users, :email, unique: true
end
end
Now the database refuses a second row with the same email. In the race above, request B’s INSERT fails even though its validation passed. The database serializes the two writes and rejects the loser, and this holds under concurrency because the check and the write happen atomically inside the database. For a deeper look at how indexes work, see our practical guide to database indexes in Rails.
Step 3: handle the exception
When the database rejects the write, Rails raises ActiveRecord::RecordNotUnique:
begin
User.create!(name: "Ada", email: "[email protected]")
rescue ActiveRecord::RecordNotUnique
# Someone else created this email between our validation and our insert.
end
In practice this should be rare, because the validation catches most duplicates first. The rescue exists for the race. You will see a full controller example later in this article.
Case sensitivity and database differences
Is [email protected] the same as [email protected]? It depends on the database and your configuration:
- SQLite and PostgreSQL compare text case-sensitively by default (SQLite uses a binary collation unless told otherwise).
- MySQL’s default collations are typically case-insensitive.
Active Record’s uniqueness validation also changed behavior here: since Rails 6.1 it respects the database’s collation by default rather than forcing a case-sensitive comparison. That means the validation and the unique index can agree on one database and disagree on another. They are not identical mechanisms, and you should not treat them as interchangeable.
You can request a case-insensitive validation:
validates :email, uniqueness: { case_sensitive: false }But this only affects the Ruby-side check, which compares with LOWER(...). A plain unique index on email still treats [email protected] and [email protected] as different values on a case-sensitive database.
The simplest and most portable solution is to normalize the value before it is saved, so every stored email is lowercase. Rails 7.1 and newer provide normalizes:
class User < ApplicationRecord
normalizes :email, with: ->(email) { email.strip.downcase }
validates :email, presence: true, uniqueness: true
end
With normalization in place, a plain unique index is enough, because the stored values are already consistent. Keep in mind that normalization only applies to writes that go through the model. A raw SQL write could still insert mixed-case values.
If you truly need case-insensitive uniqueness enforced by the database regardless of the writer, use a functional (expression) index. On PostgreSQL:
add_index :users, "lower(email)", unique: true, name: "index_users_on_lower_email"
Recent versions of SQLite also support indexes on expressions, but PostgreSQL’s support is the most commonly used and best documented in Rails applications. PostgreSQL also offers the citext extension for case-insensitive text columns. Whichever approach you choose, test it on the database you run in production.
Implementing Database Constraints in Rails Migrations
Rails migrations can express most common constraints without raw SQL. For more on writing safe, reversible migrations, see our guide to Rails database migration best practices.
A table with constraints from the start
class CreateProducts < ActiveRecord::Migration[8.1]
def change
create_table :products do |t|
t.string :name, null: false
t.integer :price_cents, null: false
t.integer :quantity, null: false, default: 0
t.string :sku, null: false
t.timestamps
t.check_constraint "price_cents > 0", name: "products_price_cents_positive"
t.check_constraint "quantity >= 0", name: "products_quantity_non_negative"
end
add_index :products, :sku, unique: true
end
end
Here null: false creates NOT NULL constraints, t.check_constraint creates CHECK constraints, and add_index ... unique: true enforces uniqueness for sku.
Adding constraints to an existing table
class AddConstraintsToOrders < ActiveRecord::Migration[8.1]
def change
change_column_null :orders, :user_id, false
add_foreign_key :orders, :users
add_check_constraint :orders, "total_cents >= 0", name: "orders_total_cents_non_negative"
end
end
change_column_null, add_foreign_key, and add_check_constraint are all reversible, so they work inside change. Rails supports check constraints on PostgreSQL, MySQL, and SQLite. SQLite has limited ALTER TABLE support, so Rails rebuilds the table behind the scenes for some of these operations, which is worth remembering on large tables.
Index vs. constraint
These two ideas overlap, but they are not the same thing:
- An index is a data structure that speeds up lookups. A unique index also rejects duplicates.
- A constraint is a rule about what data is allowed.
In Rails, a unique index is the usual way to enforce uniqueness, and it is what add_index ..., unique: true creates. A plain, non-unique index never enforces anything; it only makes reads faster. Likewise, a foreign key constraint does not automatically give you an index on the referencing column in every database. t.references adds an index by default in Rails, but a bare add_foreign_key does not.
PostgreSQL also supports named unique constraints, which Rails exposes as add_unique_constraint. They are backed by a unique index and behave similarly for most purposes, but they are a distinct database object, and they can support features like deferrable checking.
Introducing constraints on a database that already has data
Adding a constraint to a table full of existing rows is where things go wrong. If any existing row violates the rule, the migration fails, and on some databases it can fail halfway through a deploy. The safe order is: find the bad data, fix it, then add the constraint.
Find duplicates, missing values, and orphans before you write the migration:
# Duplicate emails
User.group(:email).having("COUNT(*) > 1").count
# Missing names
User.where(name: nil).count
# Orders that point to users that no longer exist
Order.where.missing(:user).count
Then clean up the data deliberately. That might mean merging duplicate accounts, filling in defaults, or deleting orphaned rows, depending on your business rules. change_column_null can fill in a default for you while it makes a column required:
change_column_null :users, :name, false, "Unknown"
On PostgreSQL, you can reduce locking and deploy risk by adding some constraints without validating existing rows immediately, then validating them in a second step:
class AddPriceCheckToProducts < ActiveRecord::Migration[8.1]
def change
add_check_constraint :products, "price_cents > 0",
name: "products_price_cents_positive", validate: false
end
end
class ValidateProductsPriceCheck < ActiveRecord::Migration[8.1]
def change
validate_check_constraint :products, name: "products_price_cents_positive"
end
end
The same validate: false and validate_foreign_key pattern exists for foreign keys. This is PostgreSQL behavior, and you should not assume it works the same way in SQLite or MySQL. For unique indexes on large PostgreSQL tables, Rails also supports building the index without blocking writes, using algorithm: :concurrently together with disable_ddl_transaction!.
Always test migrations against a copy of production data first. A constraint that passes on an empty development database can fail in production because of a few bad rows nobody knew about.
Foreign Keys vs. Active Record Associations
Associations and foreign keys look related, and they often appear together, but they are different things.
An association such as belongs_to or has_many is Ruby code that tells Active Record how models relate and gives you convenient methods. A foreign key is a database constraint that guarantees the relationship is valid. For a refresher on the Rails side, read how Active Record associations work in Ruby on Rails.
An association and its foreign key
class User < ApplicationRecord
has_many :orders
end
class Order < ApplicationRecord
belongs_to :user
end
class CreateOrders < ActiveRecord::Migration[8.1]
def change
create_table :orders do |t|
t.references :user, null: false, foreign_key: true
t.integer :total_cents, null: false, default: 0
t.timestamps
end
end
end
In this migration, t.references :user creates a user_id column and an index, null: false makes it required, and foreign_key: true adds the database foreign key.
It is easy to assume belongs_to does all of this. It does not. In modern Rails apps, belongs_to is required by default, which adds a presence validation, but it does not create a NOT NULL constraint or a foreign key. If you skip foreign_key: true, the database will happily store an orders.user_id that points nowhere.
Preventing orphaned records
A foreign key stops two kinds of mistakes: inserting a child row that references a nonexistent parent, and deleting a parent that still has children (unless you choose a different behavior). Either one, left unchecked, creates orphaned records that cause nil errors in views and quietly corrupt reports.
on_delete behavior
You choose what happens when a parent row is deleted:
add_foreign_key :orders, :users, on_delete: :restrict
add_foreign_key :comments, :posts, on_delete: :cascade
add_foreign_key :posts, :users, column: :author_id, on_delete: :nullify
:restrictblocks the deletion if child rows exist. Use it when a parent should never disappear while children depend on it, such as a customer with orders.:cascadedeletes child rows automatically. Use it for data that has no meaning without its parent, such as comments on a post.:nullifysets the child’s foreign key toNULL. This requires the column to allowNULL, so it does not combine withnull: false.
Rails exposes :cascade, :nullify, and :restrict as on_delete options, and support details can vary by adapter.
dependent vs. on_delete
These two options are related but not interchangeable:
class Post < ApplicationRecord
has_many :comments, dependent: :destroy
end
dependent: :destroy runs in Ruby. It loads each comment and calls destroy, so callbacks run, associated files are cleaned up, and every step can be observed in logs. But it only works when you delete through Active Record, and it can be slow for large collections.
on_delete: :cascade runs inside the database. It is fast and applies to every writer, but it runs no Ruby callbacks. If your comments have Active Storage attachments, for example, a database cascade will not purge those files.
A practical approach:
- Add the foreign key in almost every case, so orphans are impossible.
- Use
on_delete: :restrict(or the default behavior) for important parent records, and pair it withdependent: :restrict_with_errorif you want a friendly error message. - Use
dependent: :destroywhen callbacks or file cleanup must run. - Use
on_delete: :cascadefor simple dependent rows without callbacks, and be sure you actually want the database to delete them silently.
CHECK Constraints vs. Numericality and Inclusion Validations
CHECK constraints are the database equivalent of simple value rules. Take a product price.
The validation
class Product < ApplicationRecord
validates :price_cents, numericality: { only_integer: true, greater_than: 0 }
validates :quantity, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
end
The matching constraint
class AddChecksToProducts < ActiveRecord::Migration[8.1]
def change
add_check_constraint :products, "price_cents > 0",
name: "products_price_cents_positive"
add_check_constraint :products, "quantity >= 0",
name: "products_quantity_non_negative"
end
end
Now both layers agree. With validations alone, save(validate: false) or update_all could write -500. With the CHECK constraint, the database refuses:
product = Product.new(name: "Course", sku: "C-1", price_cents: -500, quantity: 1)
product.save(validate: false)
# => raises ActiveRecord::CheckViolation (Rails 7.1 and newer)
ActiveRecord::CheckViolation is a subclass of ActiveRecord::StatementInvalid, so older code that rescues StatementInvalid still catches it.
Status values
A list of allowed values is another good fit. Pair an inclusion rule or an enum with a check:
class Order < ApplicationRecord
enum :status, { pending: "pending", paid: "paid", shipped: "shipped", cancelled: "cancelled" },
validate: true
end
add_check_constraint :orders,
"status IN ('pending', 'paid', 'shipped', 'cancelled')",
name: "orders_status_allowed"
Using string-backed values keeps the database check readable. The validate: true option makes an invalid enum value produce a normal validation error instead of raising ArgumentError. One trade-off to keep in mind is that adding a new status later means changing both the model and the constraint.
Limits of CHECK constraints
A CHECK constraint evaluates a condition about a single row. That makes it excellent for ranges, allowed lists, and relationships between columns on the same row, such as ends_at > starts_at. It is a poor fit for:
- Rules that need data from other rows or other tables, such as “a user can have at most five active projects”. Many databases do not allow subqueries in
CHECKexpressions at all. - Rules that depend on application state, the current user, or external services.
- Rules that change often, since changing a constraint is a migration, not a one-line code edit.
Syntax and feature support also vary by adapter. SQLite, PostgreSQL, and MySQL all support CHECK, but expression functions and error messages differ, and databases may treat NULL in a check expression differently from what you expect (a CHECK passes when the expression evaluates to NULL, which is why NOT NULL is a separate constraint). Cross-row rules are better handled by validations, unique indexes on the right column combination, database-specific features like PostgreSQL exclusion constraints, or explicit locking.
When Should You Use Rails Validations?
Use validations whenever a person might trigger a rule and needs to understand what went wrong. They are the right tool for:
- User-friendly messages. “Email is invalid” is far better than a stack trace.
- Conditional rules.
validates :company_name, presence: true, if: :business_account? - Custom business logic. Date ranges, allowed state transitions, or rules that call other Ruby objects.
- Format checking. Email shapes, slugs, postal codes.
- Form feedback. Errors tied to specific fields that you can show beside inputs.
A practical example:
class Invitation < ApplicationRecord
belongs_to :organization
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :role, inclusion: { in: %w[member admin] }
validates :expires_at, presence: true
validate :expiry_is_in_the_future, on: :create
private
def expiry_is_in_the_future
return if expires_at.blank?
errors.add(:expires_at, "must be in the future") if expires_at <= Time.current
end
end
This is the kind of rule a database constraint cannot express well. “In the future” depends on the current time, and the constraint would be checked on every update, not only at creation. The validation runs at the right moment, with a clear message.
Not every business rule belongs in the database. Rules that are complex, change frequently, depend on context, or exist mainly for usability are usually better as validations. Duplicating them in SQL adds maintenance cost without adding much safety.
When Should You Use Database Constraints?
Use constraints for rules where a violation means the data is wrong, regardless of how it got there:
- Required values. If a row is meaningless without a column, make it
NOT NULL. - Uniqueness. Emails, usernames, slugs, API tokens, and join table pairs should have unique indexes.
- Referential integrity. Any column that points to another table should have a foreign key.
- Valid ranges. Non-negative quantities, positive prices, and allowed status values suit
CHECKconstraints.
Constraints become more important as your system grows:
- Concurrent writes. Multiple Puma workers, threads, and servers make races normal, not exceptional.
- Background jobs. Jobs retry, run in parallel, and may use
update_allorinsert_allfor speed. - Shared databases. Analytics tools, admin consoles, data imports, and other services may write directly to your tables.
- Multiple data access paths. Over time a codebase accumulates scripts, rake tasks, and one-off fixes that bypass models.
A useful test: if a bug elsewhere wrote this bad value, would you want the database to stop it? If yes, add a constraint. Here is a compact example for a join table that must not contain duplicate pairs:
class CreateEnrollments < ActiveRecord::Migration[8.1]
def change
create_table :enrollments do |t|
t.references :student, null: false, foreign_key: true
t.references :course, null: false, foreign_key: true
t.timestamps
end
add_index :enrollments, [:student_id, :course_id], unique: true
end
end
A student can enroll in a course only once, and neither side can reference a missing record, even when two requests race.
Using Rails Validations and Database Constraints Together
Here is a complete User example that uses both layers, with each one doing the job it is best at.
The migration
class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :name, null: false
t.string :email, null: false
t.timestamps
end
add_index :users, :email, unique: true
end
end
The model
class User < ApplicationRecord
normalizes :email, with: ->(email) { email.strip.downcase }
validates :name, presence: true, length: { maximum: 80 }
validates :email, presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP },
uniqueness: true
end
Who does what
- Validations catch the normal cases early, before any SQL write, and give the user precise messages: blank name, malformed email, email already taken.
- Normalization keeps stored emails consistent so the unique index behaves predictably.
NOT NULLguarantees no row exists without a name or email, even if a validation is skipped.- The unique index guarantees no two rows share an email, even under concurrent requests.
Handling constraint violations gracefully
The remaining gap is the race. The validation passes, and then the database rejects the insert. Handle that case by turning the exception into a normal form error:
class UsersController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
redirect_to @user, notice: "Account created."
else
render :new, status: :unprocessable_entity
end
rescue ActiveRecord::RecordNotUnique
@user.errors.add(:email, :taken)
render :new, status: :unprocessable_entity
end
private
def user_params
params.expect(user: [:name, :email])
end
end
The user sees “Email has already been taken” in both cases and never sees a database error. Rescue the specific exception, not StandardError or ActiveRecord::StatementInvalid. If a table has more than one unique index, this rescue assumes the email index was the one that failed, so inspect the error or the constraint name if that assumption could be wrong. Methods like create_or_find_by also rely on a unique constraint to work correctly, which is one more reason to have the index.
Transactions
save wraps the write, its validations, and its callbacks in a database transaction. If something fails, the whole operation rolls back. That is useful, but it does not remove the race: at the default isolation levels, two transactions can still both see “no existing email” and then both try to insert. Only the unique index closes that gap.
If you rescue a constraint error inside a larger transaction on PostgreSQL, the failed statement aborts the entire transaction, and you cannot keep running queries in it. Use a nested transaction with requires_new: true (a savepoint) when you need to recover and continue. For more on transaction behavior, see our guide to Rails transactions.
Testing Validations and Database Constraints
Each layer needs its own tests, because passing tests for one say nothing about the other. The examples below use Minitest, the Rails default.
Testing validations
require "test_helper"
class UserTest < ActiveSupport::TestCase
test "is invalid without an email" do
user = User.new(name: "Ada", email: nil)
assert_not user.valid?
assert_includes user.errors[:email], "can't be blank"
end
test "is invalid with a duplicate email" do
User.create!(name: "Ada", email: "[email protected]")
duplicate = User.new(name: "Ada Two", email: "[email protected]")
assert_not duplicate.valid?
assert_includes duplicate.errors[:email], "has already been taken"
end
end
class ProductTest < ActiveSupport::TestCase
test "price must be greater than zero" do
product = Product.new(name: "Course", sku: "C-1", price_cents: 0, quantity: 1)
assert_not product.valid?
assert_includes product.errors[:price_cents], "must be greater than 0"
end
end
These tests check behavior the user sees: whether the record is valid and which messages appear.
Testing database constraints directly
To test the database, bypass the validations so the database is the only thing that can say no:
class UserConstraintsTest < ActiveSupport::TestCase
test "database rejects a null email" do
user = User.new(name: "Ada", email: nil)
assert_raises(ActiveRecord::NotNullViolation) do
user.save!(validate: false)
end
end
test "database rejects duplicate emails" do
User.create!(name: "Ada", email: "[email protected]")
assert_raises(ActiveRecord::RecordNotUnique) do
User.new(name: "Ada Two", email: "[email protected]").save!(validate: false)
end
end
end
class ProductConstraintsTest < ActiveSupport::TestCase
test "database rejects a non-positive price" do
product = Product.new(name: "Course", sku: "C-1", price_cents: 0, quantity: 1)
assert_raises(ActiveRecord::CheckViolation) do
product.save!(validate: false)
end
end
end
class OrderConstraintsTest < ActiveSupport::TestCase
test "database rejects an order for a missing user" do
order = Order.new(user_id: 0, total_cents: 100)
assert_raises(ActiveRecord::InvalidForeignKey) do
order.save!(validate: false)
end
end
end
Why a validation test is not enough
A passing test like “is invalid with a duplicate email” only proves the model’s validation works. If you forgot the unique index, that test still passes, and your app is still vulnerable to the race. Only a test that bypasses validations and expects RecordNotUnique proves the index exists. The same reasoning applies to NOT NULL, foreign keys, and CHECK constraints.
You can also inspect the schema directly for a lighter-weight check:
test "users has a unique index on email" do
assert ActiveRecord::Base.connection.index_exists?(:users, :email, unique: true)
end
test "products has the price check constraint" do
names = ActiveRecord::Base.connection.check_constraints(:products).map(&:name)
assert_includes names, "products_price_cents_positive"
end
Run these tests against the same database adapter you use in production whenever possible, because error classes, messages, and supported features can differ between adapters. Fixtures are loaded in a way that can temporarily bypass foreign key checks, so write explicit tests like the ones above rather than assuming fixtures will reveal missing keys.
Common Mistakes and Best Practices
Here are the mistakes that cause the most trouble in real Rails applications, and what to do instead.
Relying only on validates :email, uniqueness: true. It is a check-then-write, and it cannot prevent duplicates under concurrency. Always back it with a unique index.
Assuming belongs_to guarantees everything. It adds a required-association validation by default, but it does not add NOT NULL or a foreign key. Use t.references ..., null: false, foreign_key: true in your migrations.
Forgetting foreign keys. Missing foreign keys are one of the most common causes of orphaned records. Add them unless you have a specific reason not to.
Treating indexes and constraints as interchangeable. A plain index speeds up queries and enforces nothing. A unique index enforces uniqueness. A foreign key is a constraint, and it does not guarantee an index on the referencing column unless you create one.
Adding constraints without checking production data. Query for violations first, clean the data, and test the migration on a production-like copy. On PostgreSQL, consider adding constraints with validate: false and validating them separately.
Assuming all adapters behave the same. Check constraint syntax, collations, deferrable constraints, concurrent index builds, and error messages differ between SQLite, PostgreSQL, and MySQL. Read your adapter’s documentation, and test on the database you deploy.
Rescuing exceptions too broadly. Rescue ActiveRecord::RecordNotUnique, ActiveRecord::NotNullViolation, or ActiveRecord::InvalidForeignKey where you expect them, not StandardError. A broad rescue hides real bugs and can leave transactions in a bad state.
Duplicating complex logic in both layers. Simple rules that must always hold belong in both places. Complicated, changing, or context-dependent rules belong in validations. Mirroring them in SQL creates two sources of truth that drift apart.
A few habits keep the two layers consistent:
- Decide for each rule whether it is a user-facing rule, a data-integrity rule, or both.
- For data-integrity rules, add the constraint in the same pull request as the validation.
- Normalize values (such as lowercasing emails) before they reach the database.
- Review
db/schema.rb(orstructure.sql) in code review, so you can see exactly which constraints exist. - Add tests for both layers, and rescue specific constraint errors at the boundary where you can show a helpful message.
Conclusion
Rails validations and database constraints are not competitors. Validations run in Ruby, give users clear feedback, and handle flexible business rules. Database constraints run in the database, protect data from every writer, and hold up under concurrency, skipped validations, and code paths you did not anticipate.
For the rules that matter most, such as required values, uniqueness, relationships between tables, and valid ranges, use both. Let the validation give a friendly message in the common case, and let the constraint be the final authority when something slips through.
Here is a practical next step: open your db/schema.rb and compare it with your models. For each validates ... uniqueness, look for a unique index. For each belongs_to, look for a foreign key and a NOT NULL column. For each numeric rule, ask whether a CHECK constraint should back it up. Finding and fixing those gaps now is much cheaper than cleaning up corrupted data later.