Introduction
Model validations run in Ruby and can be bypassed. Database constraints are the last line of defense, ensuring data integrity regardless of how data enters the database. A well-designed schema uses both model validations (for user-friendly error messages) and database constraints (for bulletproof integrity).
Key Concepts
- NOT NULL constraint: Prevents a column from storing NULL values. The database equivalent of
validates :field, presence: true. - UNIQUE constraint/index: Ensures no two rows share the same value. The database equivalent of
validates :field, uniqueness: true. - CHECK constraint: Enforces arbitrary boolean expressions on column values (e.g.,
price > 0). - Foreign key constraint: Ensures referential integrity between related tables. Prevents orphaned records.
- Exclusion constraint (PostgreSQL): Prevents overlapping ranges or conflicting values.
Real World Context
Race conditions can bypass model-level uniqueness validations: two requests check for uniqueness simultaneously, both pass, and both insert. Only a database UNIQUE index catches this. Similarly, update_column and raw SQL bypass all model validations. If your business cannot tolerate negative balances or duplicate emails, database constraints are mandatory.
Deep Dive
NOT NULL Constraints
rubyclass AddNotNullToUsers < ActiveRecord::Migration[8.1] def change change_column_null :users, :email, false change_column_null :users, :name, false end end
This raises a database error if any code attempts to save a NULL email, even through update_column or raw SQL.
UNIQUE Indexes
rubyclass AddUniqueEmailIndex < ActiveRecord::Migration[8.1] def change add_index :users, :email, unique: true # Conditional unique index (only active users) add_index :users, :username, unique: true, where: "deleted_at IS NULL", name: "index_active_users_on_username" end end
A conditional unique index (also called a partial index) is useful for soft-delete patterns where deleted records should not block new registrations.
CHECK Constraints
rubyclass AddCheckConstraints < ActiveRecord::Migration[8.1] def change add_check_constraint :products, "price > 0", name: "products_price_positive" add_check_constraint :products, "quantity >= 0", name: "products_quantity_non_negative" add_check_constraint :orders, "status IN ('pending', 'processing', 'shipped', 'delivered')", name: "orders_valid_status" end end
CHECK constraints enforce domain rules at the database level. They are supported in PostgreSQL, MySQL 8.0+, and SQLite.
Foreign Key Constraints
rubyclass AddForeignKeys < ActiveRecord::Migration[8.1] def change add_foreign_key :orders, :users, on_delete: :cascade add_foreign_key :comments, :articles, on_delete: :nullify add_foreign_key :line_items, :orders, on_delete: :restrict end end
The on_delete option controls what happens when the parent is deleted:
:cascade-- deletes child records automatically:nullify-- sets the foreign key to NULL:restrict-- prevents deletion if children exist
Exclusion Constraints (PostgreSQL)
rubyclass AddExclusionConstraint < ActiveRecord::Migration[8.1] def up execute <<-SQL ALTER TABLE reservations ADD CONSTRAINT no_overlapping_reservations EXCLUDE USING gist ( room_id WITH =, daterange(check_in, check_out) WITH && ); SQL end def down execute "ALTER TABLE reservations DROP CONSTRAINT no_overlapping_reservations" end end
Exclusion constraints prevent overlapping date ranges, making them perfect for booking systems.
Common Pitfalls
- Forgetting to handle database errors -- When a constraint is violated, ActiveRecord raises
ActiveRecord::StatementInvalid. Rescue it and present a user-friendly message. - Duplicate validation logic -- Having
validates :email, uniqueness: truein the model AND a unique index is correct. The model validation gives a nice error message; the index prevents race conditions. - Missing foreign keys -- Rails does not add foreign key constraints by default. Always add them explicitly for referential integrity.
Best Practices
- Layer both model validations and database constraints -- Model validations for UX, database constraints for integrity.
- Name your constraints -- Always use the
name:option so error messages and migration rollbacks are clear. - Use
on_delete: :cascadecarefully -- Cascading deletes can remove more data than intended. Prefer:restrictand handle deletion explicitly.
Summary
- NOT NULL, UNIQUE, CHECK, and foreign key constraints enforce data integrity at the database level.
- Model validations can be bypassed; database constraints cannot.
- Use both layers: model validations for user-friendly errors, constraints for bulletproof integrity.
- Exclusion constraints (PostgreSQL) prevent overlapping ranges.
- Always name constraints explicitly for maintainability.
Code Examples
class AddConstraints < ActiveRecord::Migration[8.1]
def change
# NOT NULL
change_column_null :users, :email, false
# UNIQUE index
add_index :users, :email, unique: true
# CHECK constraint
add_check_constraint :accounts, "balance >= 0",
name: "accounts_positive_balance"
# Foreign key with cascade delete
add_foreign_key :orders, :users, on_delete: :cascade
end
end