Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 009 / 100

Active Record basics: a Ruby on Rails guide for developers

Rails' ORM for working with databases.

Expona Admin6 min read

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:

ruby
class Product < ApplicationRecord
  validates :name, presence: true
  validates :price, numericality: { greater_than: 0 }
end

That'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:

ruby
class Product < ApplicationRecord
  belongs_to :category
end

class Category < ApplicationRecord
  has_many :products
end

Active 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:

ruby
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:

ruby
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 = scope

Rails converts that chain into a single SQL query, not four separate ones. The SQL generated for the above looks roughly like:

ruby
SELECT * FROM products 
WHERE active = true 
  AND category_id = ? 
ORDER BY created_at DESC 
LIMIT 20

You can also use scopes to encapsulate reusable query logic:

ruby
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:

ruby
# 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:

ruby
# 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:

ruby
# Delete one
product = Product.find(id)
product.destroy

# Delete many
Product.where(category_id: 5).destroy_all

One 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:

ruby
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
end

Callbacks 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:

ruby
# 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
end

This 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 .validate explicitly. Use validates in your model and check .valid? or use save! 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: :destroy or dependent: :nullify.
  • Not using transactions: Multiple database operations should often be wrapped in ActiveRecord::Base.transaction to 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! and update! for strict validation; use delete_all for 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles