Active Record basics: a Ruby on Rails guide for developers
If you've built anything beyond toy Rails projects, you've felt the friction of raw SQL or struggled with object-relational mapping complexity. Active Record, Rails' built-in ORM, removes most of that pain by letting you work with databases as if they were Ruby objects—no schema knowledge required in your application code. In this guide, we'll walk through the foundations that make Active Record one of Rails' greatest strengths.
Understanding Models as database table mappings
A Rails model is a Ruby class that inherits from ApplicationRecord and maps directly to a database table. The naming convention is simple: a model named Product maps to a products table. Active Record handles the plumbing—you define the class, and it introspects the table schema automatically.
Here's what a basic model looks like:
class Product < ApplicationRecord
validates :name, presence: true
validates :price, numericality: { greater_than: 0 }
endThat's genuinely all you need. Create a migration to add the products table with columns like id, name, price, created_at, and updated_at, run it, and your model is ready. The id is automatically your primary key. The timestamp columns are managed by Rails—no manual intervention required.
This simplicity is deceptive. Behind the scenes, Active Record reads your table structure and dynamically generates accessor methods for each column. When you call product.name or product.price = 29.99, Active Record knows exactly where those values live in the database. No boilerplate, no XML mappings, no explicit field declarations.
As your application grows, you'll add associations—relationships between models. A product belongs to a category, a category has many products:
class Product < ApplicationRecord
belongs_to :category
end
class Category < ApplicationRecord
has_many :products
endActive Record uses naming conventions to wire these up. The products table needs a category_id foreign key column, and suddenly you can write code like product.category.name with zero plumbing. That's the promise of Active Record: conventions over configuration.
Querying with chainable, lazy evaluation
One of Active Record's superpowers is its chainable query interface. Queries aren't executed immediately—they're built up lazily until you actually need the results. This lets you compose complex queries without multiple database round trips.
Consider this real-world scenario: you need to find the 20 most recent active products, sorted by creation date:
Product.where(active: true).order(created_at: :desc).limit(20)Each method returns an ActiveRecord::Relation object—a lazy query that hasn't hit the database yet. Only when you iterate over the results (in a view, or by calling .to_a, .each, .first, etc.) does Rails fire the SQL. This means you can pass query objects around your codebase, combine them conditionally, and still execute efficiently:
scope = Product.where(active: true).order(created_at: :desc)
scope = scope.where(category_id: params[:category_id]) if params[:category_id].present?
scope = scope.limit(params[:limit] || 20)
@products = scopeRails converts that chain into a single SQL query, not four separate ones. The SQL generated for the above looks roughly like:
SELECT * FROM products
WHERE active = true
AND category_id = ?
ORDER BY created_at DESC
LIMIT 20You can also use scopes to encapsulate reusable query logic:
class Product < ApplicationRecord
scope :active, -> { where(active: true) }
scope :recent, -> { order(created_at: :desc) }
scope :limited, ->(count) { limit(count) }
end
# Then chain them:
Product.active.recent.limited(20)This keeps your controllers and views clean and makes queries testable. Common filtering logic lives in one place—your model.
Creating, updating, and destroying records
Active Record gives you multiple ways to persist data. The most common patterns are create, update, and destroy.
Creating records is straightforward:
# Method 1: instantiate then save
product = Product.new(name: "Widget", price: 19.99)
product.save
# Method 2: create in one call
product = Product.create(name: "Widget", price: 19.99)
# Method 3: create! raises an exception if validation fails
product = Product.create!(name: "Widget", price: 19.99)The difference between create and create! matters. create returns the object whether validation passed or failed—you check product.errors afterward. create! throws an exception on failure, useful for scripts or when you absolutely expect success.
Updating records follows similar patterns:
# Method 1: find, modify, save
product = Product.find(id)
product.price = 29.99
product.save
# Method 2: update in one call
product.update(price: 29.99)
# Method 3: update on a collection
Product.where(category_id: 5).update_all(discount: 0.1)The update method runs validations and returns true or false. Use update! if you want exceptions on validation failure. The update_all variant is efficient for bulk changes—it generates a single UPDATE statement without loading every record into memory.
Destroying records is equally simple:
# Delete one
product = Product.find(id)
product.destroy
# Delete many
Product.where(category_id: 5).destroy_allOne subtlety: destroy runs callbacks and associations cleanup, while delete doesn't. Use destroy for records tied to complex relationships, and delete when you're certain no dependent logic needs to run.
Callbacks: powerful but use sparingly
Active Record callbacks let you hook into the record lifecycle. Before or after creation, validation, update, destroy—you can run custom code. They're useful for core business rules, but easy to abuse.
Common callbacks are before_create, after_create, before_update, after_update, before_destroy, after_destroy, and validation callbacks like before_validation:
class Product < ApplicationRecord
before_create :set_default_sku
after_create :notify_warehouse
before_destroy :archive_inventory_log
private
def set_default_sku
self.sku ||= SecureRandom.hex(8).upcase
end
def notify_warehouse
WarehouseService.notify(self)
end
def archive_inventory_log
InventoryLog.create(product_id: id, action: "destroyed")
end
endCallbacks are tempting for side effects—sending emails, updating caches, triggering external APIs. But this couples your model tightly to business logic, making it hard to test and reason about. Consider using callbacks for data consistency (setting defaults, normalizing input), and reach for service objects or job queues for complex operations:
# Better for side effects:
class CreateProductService
def initialize(params)
@params = params
end
def call
product = Product.create!(@params)
NotifyWarehouseJob.perform_later(product.id)
product
end
endThis separates concerns. The model stays lean; the service orchestrates the workflow. Testing is easier—you mock the job, not the callback.
Common mistakes
- N+1 queries: Loading records in a loop without eager loading. Use
.includes(:association)to fetch related data in one query.Product.includes(:category).each { |p| puts p.category.name }fires one query instead of N+1. - Overusing callbacks: Callbacks make data flow hard to trace. Prefer service objects or concerns for business logic.
- Forgetting validations: Validations don't run on update operations unless you call
.validateexplicitly. Usevalidatesin your model and check.valid?or usesave!to catch violations. - Destroying without dependent handling: If a category is destroyed but products aren't cleaned up, you'll have orphaned records. Specify
has_many :products, dependent: :destroyordependent: :nullify. - Not using transactions: Multiple database operations should often be wrapped in
ActiveRecord::Base.transactionto ensure atomicity.
Key takeaways
- Models map to tables by convention. Define a class inheriting from
ApplicationRecord, and Active Record introspects your schema automatically. - Queries are lazy and chainable. Build complex queries with
where,order,limit, and scopes without multiple database hits. - Create, update, and destroy operations support both single-record and bulk variants. Use
create!andupdate!for strict validation; usedelete_allfor performance-critical bulk deletes without callbacks. - Callbacks are useful for data consistency but dangerous for side effects. Keep callbacks minimal; delegate business logic to service objects or job queues.
- Always consider associations and eager loading. Lazy-load association counts to avoid N+1 queries in production.






Comments
Loading comments…