Ruby on Rails Zero to Advanced #6: Active Record, Migrations & CRUD
So far our articles lived in a hard-coded array. In this part we give them a real home: a database table managed by Active Record, Rails' ORM (Object-Relational Mapper). You will create tables with migrations, generate models, and practise CRUD (Create, Read, Update, Delete) in the Rails console while reading the SQL that Active Record writes for you.
Generating a model and its migration
One generator creates both the model class and the migration that builds its table:
bin/rails generate model Article title:string body:text published:boolean
Output:
invoke active_record
create db/migrate/20261013031500_create_articles.rb
create app/models/article.rb
invoke test_unit
create test/models/article_test.rb
create test/fixtures/articles.yml
Naming convention: the model is singular (Article), the table is plural (articles). The migration is a Ruby description of the schema change. Before running it, add sensible constraints:
# db/migrate/20261013031500_create_articles.rb
class CreateArticles < ActiveRecord::Migration[8.0]
def change
create_table :articles do |t|
t.string :title, null: false
t.text :body
t.boolean :published, default: false, null: false
t.timestamps
end
end
end
t.timestamps adds created_at and updated_at, which Rails fills in automatically. An id primary key is added without asking. The model itself is almost empty, and that is fine: Active Record reads the columns from the database.
# app/models/article.rb
class Article < ApplicationRecord
end
Running and rolling back migrations
bin/rails db:migrate
Output:
== 20261013031500 CreateArticles: migrating ===================================
-- create_table(:articles)
-> 0.0012s
== 20261013031500 CreateArticles: migrated (0.0013s) ==========================
Rails records which migrations have run and updates db/schema.rb, the current snapshot of your database. Need another column later? Never edit an old migration that has already run; create a new one:
bin/rails generate migration AddAuthorNameToArticles author_name:string
bin/rails db:migrate
bin/rails db:migrate:status
Output:
invoke active_record
create db/migrate/20261013032000_add_author_name_to_articles.rb
== 20261013032000 AddAuthorNameToArticles: migrating ==========================
-- add_column(:articles, :author_name, :string)
-> 0.0009s
== 20261013032000 AddAuthorNameToArticles: migrated (0.0010s) =================
database: storage/development.sqlite3
Status Migration ID Migration Name
--------------------------------------------------
up 20261013031500 Create articles
up 20261013032000 Add author name to articles
Made a mistake in the last migration you just ran locally? bin/rails db:rollback reverts it so you can fix it and migrate again.
Create: adding records
Open the console with bin/rails console (or bin/rails c). It is irb with your whole app loaded, and in development it logs every SQL query.
article = Article.new(title: "Hello Rails", body: "My first post", author_name: "Budi")
article.persisted?
article.save
article.id
Output:
=>
#<Article:0x000000012d4f1a38
id: nil,
title: "Hello Rails",
body: "My first post",
published: false,
created_at: nil,
updated_at: nil,
author_name: "Budi">
=> false
TRANSACTION (0.1ms) BEGIN immediate TRANSACTION
Article Create (0.6ms) INSERT INTO "articles" ("title", "body", "published", "created_at", "updated_at", "author_name") VALUES (?, ?, ?, ?, ?, ?) RETURNING "id" [["title", "Hello Rails"], ["body", "My first post"], ["published", 0], ["created_at", "2026-10-13 03:20:11.482915"], ["updated_at", "2026-10-13 03:20:11.482915"], ["author_name", "Budi"]]
TRANSACTION (0.2ms) COMMIT TRANSACTION
=> true
=> 1
new + save is a two-step create; create does both at once. Let's add two more records:
Article.create(title: "Active Record tips", body: "Use scopes", published: true, author_name: "Siti")
Article.create(title: "Draft ideas", author_name: "Budi")
Article.count
Output (SQL shortened):
Article Create (0.4ms) INSERT INTO "articles" ... RETURNING "id"
=> #<Article:0x000000012e0a7b10 id: 2, title: "Active Record tips", ...>
Article Create (0.3ms) INSERT INTO "articles" ... RETURNING "id"
=> #<Article:0x000000012e1c3d58 id: 3, title: "Draft ideas", ...>
Article Count (0.1ms) SELECT COUNT(*) FROM "articles"
=> 3
Read: finding and querying
Article.find(2).title
Article.find_by(author_name: "Siti").title
Article.find_by(title: "Does not exist")
Article.find(99)
Output:
Article Load (0.1ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
=> "Active Record tips"
Article Load (0.1ms) SELECT "articles".* FROM "articles" WHERE "articles"."author_name" = ? LIMIT ? [["author_name", "Siti"], ["LIMIT", 1]]
=> "Active Record tips"
Article Load (0.1ms) SELECT "articles".* FROM "articles" WHERE "articles"."title" = ? LIMIT ? [["title", "Does not exist"], ["LIMIT", 1]]
=> nil
Article Load (0.1ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ? [["id", 99], ["LIMIT", 1]]
(blog):4:in '<main>': Couldn't find Article with 'id'=99 (ActiveRecord::RecordNotFound)
find raises when nothing matches (in a controller this becomes a 404 page), while find_by returns nil. For lists, chain query methods. They build a lazy ActiveRecord::Relation that only hits the database when you use the result:
Article.where(author_name: "Budi").order(created_at: :desc).pluck(:title)
Article.where(published: false).count
Article.where("title LIKE ?", "%Rails%").to_sql
Output:
Article Pluck (0.2ms) SELECT "articles"."title" FROM "articles" WHERE "articles"."author_name" = ? ORDER BY "articles"."created_at" DESC [["author_name", "Budi"]]
=> ["Draft ideas", "Hello Rails"]
Article Count (0.1ms) SELECT COUNT(*) FROM "articles" WHERE "articles"."published" = ? [["published", 0]]
=> 2
=> "SELECT \"articles\".* FROM \"articles\" WHERE (title LIKE '%Rails%')"
Always pass user input through ? placeholders or hash conditions, never string interpolation. Active Record then escapes the value and protects you from SQL injection.
Update and Delete
article = Article.find(1)
article.update(published: true)
article.published?
draft = Article.find(3)
draft.destroy
Article.count
Output:
Article Load (0.1ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<Article:0x000000012f3a5c20 id: 1, title: "Hello Rails", ...>
TRANSACTION (0.1ms) BEGIN immediate TRANSACTION
Article Update (0.3ms) UPDATE "articles" SET "published" = ?, "updated_at" = ? WHERE "articles"."id" = ? [["published", 1], ["updated_at", "2026-10-13 03:25:40.117263"], ["id", 1]]
TRANSACTION (0.1ms) COMMIT TRANSACTION
=> true
=> true
Article Load (0.1ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]]
=> #<Article:0x000000012f4b8e90 id: 3, title: "Draft ideas", ...>
TRANSACTION (0.1ms) BEGIN immediate TRANSACTION
Article Destroy (0.3ms) DELETE FROM "articles" WHERE "articles"."id" = ? [["id", 3]]
TRANSACTION (0.1ms) COMMIT TRANSACTION
=> #<Article:0x000000012f4b8e90 id: 3, title: "Draft ideas", ...>
Article Count (0.1ms) SELECT COUNT(*) FROM "articles"
=> 2
Note that update only writes the changed column plus updated_at. Bulk versions exist too (update_all, delete_all), but they skip validations and callbacks, so use them deliberately.
Now the controller from part 5 can use the database instead of an array:
class ArticlesController < ApplicationController
def index
@articles = Article.where(published: true).order(created_at: :desc)
end
def show
@article = Article.find(params[:id])
end
end
In views, use methods instead of hash keys: article.title rather than article[:title], and pass the object directly to helpers: link_to article.title, article.
Seeding sample data
Put development data in db/seeds.rb so every teammate can recreate it. find_or_create_by! makes the file safe to run repeatedly:
# db/seeds.rb
[
{ title: "Hello Rails", author_name: "Budi", published: true },
{ title: "Active Record tips", author_name: "Siti", published: true }
].each do |attrs|
Article.find_or_create_by!(title: attrs[:title]) do |article|
article.assign_attributes(attrs)
end
end
puts "Articles: #{Article.count}"
bin/rails db:seed
Output:
Articles: 2
Common mistakes
- Editing a migration that already ran. Your teammates and production will never re-run it. Write a new migration instead.
- Forgetting to migrate. Rails shows
ActiveRecord::PendingMigrationErrorin the browser. Runbin/rails db:migrate. - Ignoring the return value of save.
savereturnsfalsewhen it fails;save!andcreate!raise an exception. In scripts and seeds, prefer the bang versions so failures are not silent. - Treating where as a single record.
Article.where(id: 1).titlefails becausewherereturns a collection. Usefind,find_byor.first. - Interpolating input into SQL.
where("title = '#{params[:q]}'")opens an SQL injection hole. Usewhere(title: params[:q]).
Exercise
Generate a Product model with name:string, price:integer and stock:integer, migrate, create three products in the console, then list the names of products cheaper than 50,000 sorted by price:
Product.create!(name: "Notebook", price: 15_000, stock: 40)
Product.create!(name: "Backpack", price: 250_000, stock: 5)
Product.create!(name: "Pen", price: 5_000, stock: 100)
Product.where("price < ?", 50_000).order(:price).pluck(:name)
Output (last line):
Product Pluck (0.2ms) SELECT "products"."name" FROM "products" WHERE (price < 50000) ORDER BY "products"."price" ASC
=> ["Pen", "Notebook"]
Summary
Migrations describe schema changes in Ruby, models map tables to classes, and the console lets you create, read, update and delete records while showing the generated SQL. You also learned the difference between find and find_by, how relations are lazy, and why placeholders matter. In part 7 we connect models with associations, protect data with validations, and react to lifecycle events with callbacks.
Related articles
PostgreSQL Zero to Advanced #5: Aggregation, GROUP BY & HAVING
Turn raw orders into business numbers: total revenue, average order value, sales per month and top customers using COUNT, SUM, AVG, GROUP BY, HAVING and FILTER.
PHP Zero to Advanced #5: Arrays & Array Functions
Master PHP arrays: indexed and associative arrays, nested data, destructuring, and the essential functions array_map, array_filter, array_reduce, usort, and friends.
Python Zero to Advanced #5: Lists, Tuples, Dictionaries, Sets & Comprehensions
Master Python's built-in collections: lists, tuples, dictionaries, and sets, when to use each, slicing, unpacking, and writing concise list, dict, and set comprehensions.