Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Ruby on Rails  Module 2 – Active Record: models and the database

Active Record models and CRUD

Follow one Active Record row through create, read, update and delete, and pick the save method that fits a change in a Rails 8.1 app.

  • Beginner
  • 15 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Create, read, update and delete records with Active Record and read the SQL each call runs
  • Choose between save, save!, update, update_columns, update_all, destroy and delete for a given change
  • Use typed attributes, type casting on assignment and dirty tracking to track and inspect changes

Before you start

What you’ll learn · Before you start

What you will learn

  • Create, read, update and delete records with Active Record and read the SQL each call runs
  • Choose between save, save!, update, update_columns, update_all, destroy and delete for a given change
  • Use typed attributes, type casting on assignment and dirty tracking to track and inspect changes

Before you start

On this page

In Kaksha, the coaching-centre app this track builds, one row of the batches table is a Class 10 Science class with 24 seats and a fee of 15,00,000 paise, which is Rs 15,000. Writing Batch.find(7) reads it in one SQL query, but writing it back is a choice: the Rails validations guide lists six methods that run the model’s validations and nineteen that skip them, so one copy-paste can leave a batch with a negative fee in the database.

This lesson follows one row through its lifetime: how a column of a table becomes a typed attribute, which read method raises and which returns nil, and which save method runs the model’s validations. Every example is a standalone Active Record: Rails' object-relational mapper: one Ruby class holds the rules, queries and lifecycle of one database table, and the table's columns become typed attributes of the class. script with an in-memory SQLite database, so the only moving parts are the model and the SQL you can watch each call run.

A column of a table becomes a typed attribute

Active Record is Rails’ object-relational mapper. One class holds the rules and queries for one table: a model’s name tells it which table to use, so Batch reaches batches. The Batch class used throughout this lesson needs no column list in Ruby. The script’s schema has name (string, not null), fee_paise (integer, not null, default 0), seats (integer, not null, default 30) and timestamps, and the model is one line:

A full CRUD tour with the SQL each call prints Ruby · crud_tour.rb
# A full CRUD tour of one Active Record model, standalone (no Rails app).
# The in-memory SQLite database lives only for the run; a schema builds the
# batches table, and the Batch class maps to it with no configuration.
# A small subscriber on sql.active_record prints only the SQL the model runs,
# so we see what each call does to the database.
require "active_record"
require "logger"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

# Keep schema setup, transactions and ar_internal_metadata out of the trace:
# just the SQL that CRUD on batches runs.
ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
  sql = event.payload[:sql]
  next if event.payload[:name].to_s =~ /SCHEMA|TRANSACTION/
  next if sql.start_with?("CREATE", "PRAGMA", "SELECT sqlite", "SELECT sql")
  next if sql.include?("ar_internal_metadata")
  puts "  SQL: #{sql.gsub(/\s+/, ' ').strip}"
end

ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
  create_table :batches do |t|
    t.string  :name,       null: false
    t.integer :fee_paise,  null: false, default: 0
    t.integer :seats,      null: false, default: 30
    t.timestamps
  end
end

class Batch < ActiveRecord::Base; end

def section(title)
  puts
  puts "== #{title} =="
end

section "Create"
science = Batch.create!(name: "Class 10 Science", fee_paise: 15_00_000, seats: 24)
puts "  New row: id=#{science.id}, name=#{science.name.inspect}"
# A second row for the find/where section below.
Batch.create!(name: "Class 10 Maths", fee_paise: 12_50_000, seats: 20)

section "Read: find, find_by, where"
puts "  find(#{science.id}) -> #{Batch.find(science.id).name.inspect}"
begin
  Batch.find(9999)
rescue ActiveRecord::RecordNotFound => e
  puts "  find(9999) raised: ActiveRecord::RecordNotFound (#{e.message[0, 48]}...)"
end
puts "  find_by(name: 'Nothing') -> #{Batch.find_by(name: "Nothing").inspect}"
rel = Batch.where("fee_paise > ?", 13_00_000)
puts "  where(...) class before use: #{rel.class.name} (nothing has run yet)"
puts "  .to_a: #{rel.to_a.map(&:name).inspect}"
puts "  first (no order): #{Batch.first.name.inspect} (ordered by primary key)"
puts "  last  (no order): #{Batch.last.name.inspect}"

section "Type casting on assignment"
science.seats = "22"           # a string from params
puts "  seats = \"22\" became #{science.seats.inspect} of #{science.seats.class.name}"
puts "  changed? #{science.changed?} ; changes: #{science.changes.inspect}"

section "Update"
science.update!(fee_paise: 16_00_000)
puts "  fee_paise now Rs #{science.fee_paise / 100}, saved_change? #{science.saved_change_to_fee_paise?}"
puts "  previous_changes keys: #{science.previous_changes.keys.sort.inspect}"

section "Delete"
science.destroy
puts "  destroyed? #{science.destroyed?} ; count after destroy: #{Batch.count}"

Output


== Create ==
  SQL: INSERT INTO "batches" ("name", "fee_paise", "seats", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?) RETURNING "id"
  New row: id=1, name="Class 10 Science"
  SQL: INSERT INTO "batches" ("name", "fee_paise", "seats", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?) RETURNING "id"

== Read: find, find_by, where ==
  SQL: SELECT "batches".* FROM "batches" WHERE "batches"."id" = ? LIMIT ?
  find(1) -> "Class 10 Science"
  SQL: SELECT "batches".* FROM "batches" WHERE "batches"."id" = ? LIMIT ?
  find(9999) raised: ActiveRecord::RecordNotFound (Couldn't find Batch with 'id'=9999...)
  SQL: SELECT "batches".* FROM "batches" WHERE "batches"."name" = ? LIMIT ?
  find_by(name: 'Nothing') -> nil
  where(...) class before use: ActiveRecord::Relation (nothing has run yet)
  SQL: SELECT "batches".* FROM "batches" WHERE (fee_paise > ?)
  .to_a: ["Class 10 Science"]
  SQL: SELECT "batches".* FROM "batches" ORDER BY "batches"."id" ASC LIMIT ?
  first (no order): "Class 10 Science" (ordered by primary key)
  SQL: SELECT "batches".* FROM "batches" ORDER BY "batches"."id" DESC LIMIT ?
  last  (no order): "Class 10 Maths"

== Type casting on assignment ==
  seats = "22" became 22 of Integer
  changed? true ; changes: {"seats" => [24, 22]}

== Update ==
  SQL: UPDATE "batches" SET "fee_paise" = ?, "seats" = ?, "updated_at" = ? WHERE "batches"."id" = ?
  fee_paise now Rs 16000, saved_change? true
  previous_changes keys: ["fee_paise", "seats", "updated_at"]

== Delete ==
  SQL: DELETE FROM "batches" WHERE "batches"."id" = ?
  SQL: SELECT COUNT(*) FROM "batches"
  destroyed? true ; count after destroy: 1

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby crud_tour.rb

Open each section of the output. In Create, Batch.create! built an INSERT that writes every column; Active Record asked the database for the table’s columns the first time the model needed them, so no mapping file is needed. In Read, find and find_by each ran one SELECT with LIMIT 1, and where built a Relation on the client. In Update, one UPDATE set fee_paise and seats and the timestamp; in Delete, one DELETE ended the row.

Now the two assignments. Setting science.seats = "22" read back as 22 of class Integer: a column type casting: Converting a value to the type a column expects when it is assigned to an attribute, so a string from a request becomes the Integer, Date or Boolean the schema holds. the string the moment it was assigned, with the same type the column holds. This is why params[:seats], which comes in as a String from a form, becomes a usable Integer as soon as it reaches the model: the model reads from the schema, not from the request.

Each assignment also showed up in dirty tracking: Active Record's record of which attributes a model has changed since it was loaded or last saved, read with changed?, changes, saved_change_to_<name>? and previous_changes.: changed? was true, changes returned {"seats" => [24, 22]} (the old value and the new), and after the update saved_change_to_fee_paise? was true. These predicates let callbacks and audit code ask what moved in the last save without a second query.

New Rails 8.1 apps hide most attributes in inspect

The generator writes config.active_record.attributes_for_inspect = [:id] in config/environments/production.rb. In the production console, batch.inspect then prints only the id and the row’s class, so a leaked attribute cannot reach a screenshot of a shoulder-surfed session. Development keeps every attribute visible.

Reading one row and reading many

Three method families read rows, and they answer a missing row in three different ways. The step through below walks each one from the Ruby call on the left to the SQL on the right, using the queries the example above printed:

Interactive · message by message find, find_by and where, with the SQL each one runs Step through the three reads of the example, from the Ruby call on the left to the SQL on the right.
find, find_by and where, with the SQL each one runs, step 9 of 9. Messages so far: Your code to Database: SELECT ... WHERE id = 1; Database to Your code: one row -> Batch; Your code to Database: SELECT ... WHERE id = 9999; Your code to Database: SELECT ... WHERE name = ?; Database to Your code: 0 rows -> nil; Your code to Database: SELECT ... WHERE fee_paise > ?; Database to Your code: rows -> Array<Batch>.Your codehas the arrayDatabase0 rowsSELECT ... WHERE id = 1one row -> BatchSELECT ... WHERE id = 9999SELECT ... WHERE name = ?0 rows -> nilSELECT ... WHERE fee_paise > ?rows -> Array<Batch>

The drawing shows the whole scene; its 9 steps are listed below.

Every step as text
  1. find(1): the row is there. Batch.find(1) runs one query with the id and LIMIT 1. The database returns the one row and Active Record builds it into a Batch object.
  2. find returned the Batch. The call hands back the Batch object. find always looks for one specific row.
  3. find(9999): no row. The same shape of query runs with an id that no row has. The database returns nothing.
  4. find raises RecordNotFound. A missing row is a problem for find. It raises ActiveRecord::RecordNotFound, which Rails controllers turn into a 404 page by default.
  5. find_by on a missing name. find_by runs the same shape of query on any attribute. It can also return no row.
  6. find_by returned nil. For find_by a missing row is not an error. The call returns nil, and the caller decides what to do with it.
  7. where: nothing has run yet. Batch.where("fee_paise > ?", 1300000) builds a Relation on the client. No SQL has run yet, so the condition can be built up piece by piece before it costs anything.
  8. Reading the Relation runs it. As soon as the Relation is read with .to_a, .each, .count or any method that needs the rows, Active Record runs one SELECT and builds every row into a Batch object.
  9. The rows come back. The database returns every row that matches. The order is not promised, so add .order(:name) when the order matters to you.

find(id) is for one specific record. If no row has that id, it raises ActiveRecord::RecordNotFound, and Rails’ default rescue turns that into a 404 response, so controllers can call find(params[:id]) without an if for the missing case. find_by(attribute: value) is for the general case: it also runs one SELECT ... LIMIT 1, but it returns nil when no row matches, which is correct when the row may not exist and the caller can decide what to do.

where(...) is different again. The script printed ActiveRecord::Relation for Batch.where("fee_paise > ?", 1_300_000) before the Relation was read. No SQL ran yet: a Relation is a plan the client builds up and only executes when a method asks for the rows (to_a, each, count, find_each and many more). This is what lets chained scopes stay cheap: Batch.where(name: "Class 10").where("fee_paise > ?", 1_300_000).order(:name) is still one query at the end.

Two shorter calls close the family. Batch.first and Batch.last run SELECT ... ORDER BY batches.id ASC LIMIT 1 and ... DESC LIMIT 1: when no order is given, they order by the primary key, which is why first returned Science, the batch created first, and last returned Maths. Add .order(:name) or any other order when the order of ids is not the one you want.

Key idea

find raises RecordNotFound, find_by returns nil, and where returns a lazy Relation; first and last order by the primary key when no order is given.

Checkpoint

Check: Reading rows with find, find_by and where

  1. Question 1 of 2 A controller runs Batch.find(params[:id]) and no row has that id. What does Rails do by default?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Rails rescues ActiveRecord::RecordNotFound and renders a 404 page

    find looks for one specific row; a missing row raises ActiveRecord::RecordNotFound, which Rails turns into a 404. Use find_by when nil is a sensible answer.

  2. Question 2 of 2 Batch.where("fee_paise > ?", 1_300_000) is called in a line of its own, with no .to_a, .each, .count or .find_each after it. How many queries does that line run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Zero

    A Relation is lazy. You can chain conditions on it with no query running. The query runs when you call to_a, each, find_each or any method that must materialise the rows.

Writing changes with validations

Validations get a lesson of their own later in this module, so Batch here has just two rules: name must be present and fee_paise must be a positive integer. The next example tries every save method on the Batch and prints what each one did:

save, save!, update, update_columns and update_all against the same batch Ruby · save_vs_update_columns.rb
# Which save method runs validations? Which touches updated_at? Standalone
# Active Record script, in-memory SQLite. Four columns: name, fee_paise,
# seats, timestamps. Two validations: name must be present and fee_paise
# must be a positive integer. The script tries each save method on the
# same model and prints what each did.
require "active_record"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
  create_table :batches do |t|
    t.string  :name,       null: false
    t.integer :fee_paise,  null: false, default: 0
    t.integer :seats,      null: false, default: 30
    t.timestamps
  end
end

class Batch < ActiveRecord::Base
  validates :name,      presence: true
  validates :fee_paise, numericality: { only_integer: true, greater_than: 0 }
end

def section(title)
  puts
  puts "== #{title} =="
end

science = Batch.create!(name: "Class 10 Science", fee_paise: 15_00_000, seats: 24)
start_timestamp = science.updated_at

section "save returns false on an invalid record"
science.name = ""
science.fee_paise = -100
puts "  save -> #{science.save.inspect}"
puts "  errors.full_messages: #{science.errors.full_messages.inspect}"
# Reset for the next step.
science.reload

section "save! raises ActiveRecord::RecordInvalid"
science.fee_paise = -5
begin
  science.save!
rescue ActiveRecord::RecordInvalid => e
  puts "  raised: ActiveRecord::RecordInvalid"
  puts "  message: #{e.message}"
end
science.reload

section "update runs validations and touches updated_at"
sleep 0.01 # make sure a new timestamp differs from the first
ok = science.update(fee_paise: 15_50_000)
puts "  update -> #{ok.inspect} ; validations ran: errors.any? #{science.errors.any?}"
puts "  updated_at changed by update: #{science.updated_at != start_timestamp}"
after_update = science.updated_at

section "update_columns skips validations and keeps updated_at"
science.update_columns(fee_paise: -1, seats: 999)
puts "  valid? after update_columns: #{science.valid?.inspect} (fee_paise -1 would fail validation)"
puts "  updated_at changed by update_columns: #{science.updated_at != after_update}"
puts "  reload shows the row in the database: fee_paise=#{science.reload.fee_paise}"
science.update!(fee_paise: 15_50_000) # repair for the next step
before_update_all = science.reload.updated_at

section "update_all also skips validations and keeps updated_at"
# Give every batch an invalid fee in one SQL round-trip.
sleep 0.01
Batch.update_all(fee_paise: -42)
science.reload
puts "  all rows now have fee_paise=#{Batch.distinct.pluck(:fee_paise).inspect}"
puts "  updated_at touched by update_all? #{science.updated_at != before_update_all}"

Output


== save returns false on an invalid record ==
  save -> false
  errors.full_messages: ["Name can't be blank", "Fee paise must be greater than 0"]

== save! raises ActiveRecord::RecordInvalid ==
  raised: ActiveRecord::RecordInvalid
  message: Validation failed: Fee paise must be greater than 0

== update runs validations and touches updated_at ==
  update -> true ; validations ran: errors.any? false
  updated_at changed by update: true

== update_columns skips validations and keeps updated_at ==
  valid? after update_columns: false (fee_paise -1 would fail validation)
  updated_at changed by update_columns: false
  reload shows the row in the database: fee_paise=-1

== update_all also skips validations and keeps updated_at ==
  all rows now have fee_paise=[-42]
  updated_at touched by update_all? false

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby save_vs_update_columns.rb

save is the baseline. It assigns nothing: you set attributes, then call save. On an invalid record it returns false and fills errors.full_messages, so a controller can render the form again with the messages. save! is the same call that raises ActiveRecord::RecordInvalid instead, which is right in a job or a script where a failure must roll back the surrounding transaction. The example reproduced both: save returned false with two messages, and save! raised RecordInvalid with the fee rule that failed in its message.

update(fee_paise: 15_50_000) wraps the two in one call: it assigns, validates, saves and returns true or false. update! raises in the same way as save!. Both touch updated_at, so a cache keyed on it or an audit log notices the row has moved. These three methods also run the before_save, after_save, before_update and after_update callbacks, so any code that keeps a counter cache, invalidates a view fragment or logs a change still runs for every change.

The Persistence API describes these three as saves that validate first; the next two are documented as writes that skip validations.

Writing without validations, and when that is right

update_columns and update_all write to the database without running validations, without running callbacks and without changing updated_at. They are a different kind of operation. The example used update_columns(fee_paise: -1, seats: 999) on the same batch: valid? right after was false, the row kept its old updated_at, and reload showed the fee really became -1. On update_all the same was true for every row the Relation covered, and the update_all API page says why: it sends a single UPDATE statement to the database and builds no Batch object for any row.

Which is right depends on where the value comes from and what else the change touches. Answer the two questions and the table rules the methods out:

Interactive · answer and compare Which save method? Answer two questions about your change. Methods that clash with your answer are marked, with the reason and the deciding columns.
Where does the value you want to save come from?
Must updated_at change (so a cache or an audit log knows the row moved)?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

The five persistence methods against four things each one changes
Method Runs validations Runs callbacks Touches updated_at On failure
save Good yes: the record must be valid before any SQL runs Good yes: before_save, after_save and the create or update callbacks run Good yes: both timestamps on a new row, updated_at on a change Mixed returns false and fills errors.full_messages
save! Good yes: the record must be valid before any SQL runs Good yes: the save callbacks run Good yes, as save does Mixed raises ActiveRecord::RecordInvalid, which rolls back the transaction
update(attributes) Good yes: assigns the attributes, then saves Good yes: the save callbacks run Good yes: a save, so updated_at changes Mixed returns false and fills errors (update! raises)
update_columns(attributes) Poor no: writes straight to the row, even when the record is invalid Poor no: before_save and after_save do not run Poor no: updated_at keeps its value until a later save Mixed raises on a database error, such as a check constraint
update_all(attributes) Poor no: one UPDATE for every row the Relation covers Poor no: never loads a row, never runs model code Poor no: updated_at keeps its value for every row Good returns the count of rows the database updated
How the answers decide
  • “Where does the value you want to save come from?” A form or API request a user filled in: rules out update_columns(attributes) and update_all(attributes). A user may send a value the validations would reject; a method that skips them lets a bad row reach the database.
  • “Must updated_at change (so a cache or an audit log knows the row moved)?” Yes, every change the row records: rules out update_columns(attributes) and update_all(attributes). update_columns and update_all keep updated_at as it was, so a cache that trusts it would still show the old row.
  • “Where does the value you want to save come from?” A one-off change on every row that matches: adds a caveat to save, save! and update(attributes). For a change on many rows at once, update_all makes one SQL round-trip; the others load every row into Ruby first.

In short: for a value a user sent (a form, an API request, a webhook that is not fully trusted), use save, save! or update so validations check it. For a repair of data your own code owns, where you have already decided the value is good, update_columns is the right tool on one row and update_all on many. For a change that must leave updated_at as it is (think of a background migration that fixes a typo in name without pretending the row has just been updated), update_columns and update_all are the only choices.

Writing user input with update_columns to go faster

A controller that reads params[:fee_paise] and calls batch.update_columns(fee_paise: params[:fee_paise].to_i) will accept a negative number because no validation ran, and will not change updated_at, so a cache keyed on it still shows the old fee. The right tool for a request from a user is update.

Checkpoint

Check: Writing changes that skip validations

  1. Question 1 of 2 batch.update_columns(fee_paise: -1) runs on a Batch whose model validates that fee_paise is greater than 0. Which of these are true? Choose all that apply.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • The row in the database ends with fee_paise = -1
    • The record is still invalid in memory (valid? returns false)

    update_columns writes the row without validations, without callbacks and without touching updated_at: the data your code owns moves, but nothing else does.

  2. Question 2 of 2 A Relation covers 1,000 batches. Batch.where(archived: false).update_all(archived: true) runs. How many SELECT queries does it run to find the rows before the UPDATE?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 0 queries

    update_all turns the Relation into one UPDATE with the WHERE clause and sends it to the database. No row is loaded into Ruby, so no SELECT runs; one UPDATE moves 1,000 rows in one round-trip. destroy_all is the opposite: it loads every row to run callbacks and dependent: rules on each.

destroy loads the row, delete does not

Removing a row has the same two families. destroy loads the record, runs before_destroy and after_destroy, follows the dependent: rules of its associations and then issues the DELETE. delete runs one DELETE on that id, with no row loaded and no callback invoked. The example’s last section shows destroy ending with one DELETE FROM "batches" WHERE "batches"."id" = ? and destroyed? returning true.

On a collection the same split holds, with the same shape of SQL:

Call Loads rows? Runs callbacks? Follows dependent: rules? SQL
batch.destroy the one row yes yes one DELETE after the callbacks
batch.delete no no no one DELETE
Batch.where(archived: true).destroy_all each matching row yes, once per row yes, per row one SELECT and N DELETEs
Batch.where(archived: true).delete_all no no no one DELETE with the WHERE clause

The difference is often the difference between a safe removal and a surprise. When a Batch has_many :students, dependent: :destroy, calling batch.destroy deletes the students first and then the batch; calling batch.delete leaves the students in the table, pointing at a batch that no longer exists. With no foreign key on students.batch_id, nothing refuses. With one, which the migrations lesson adds, the database refuses the DELETE and Active Record raises ActiveRecord::InvalidForeignKey.

Checkpoint

Check: Removing rows with destroy and delete

  1. Question 1 of 2 A Batch has_many :students, dependent: :destroy. Calling batch.destroy runs which deletes?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A DELETE for each student of the batch, then a DELETE for the batch

    destroy loads the row, runs the before_destroy callbacks, follows dependent: :destroy for every association, and issues the DELETE. It is right when callbacks or child rows must be handled.

  2. Question 2 of 2 The same Batch has_many :students, dependent: :destroy, but a developer wrote batch.delete to go faster. students.batch_id has no foreign key. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The batch is deleted and the students stay, pointing at a batch that is gone

    delete bypasses the model, so a dependent: rule written on the association never runs. The migrations lesson adds a foreign key, which turns this surprise into a loud database error.

Practise: write the two methods a Batch needs

Copy save_vs_update_columns.rb into a new file. It already opens an in-memory SQLite database and defines a Batch with a required name and a fee above zero. Add a last_class_on date and a nullable archived_at datetime to its create_table, then build the two methods below and run the file with ruby after each step.

  1. Batch.archive_stale!(cutoff) sets archived_at to Time.current on every batch whose last class came before cutoff and that is not archived yet, and returns how many it archived. Each archived batch must pass its validations and get a new updated_at, so pick the write method with the save-method table in mind.
  2. Batch#raise_fee!(paise) adds paise, which may be negative, to fee_paise and saves. A change that would leave the fee at zero or below raises ActiveRecord::RecordInvalid and leaves the row as it was; a good change moves updated_at.
  3. Check both with puts. Create three batches with different last-class dates, archive with a cutoff that falls between them and print the count. Then call raise_fee!(-20_00_000) on a fee of 15,00,000 paise inside a begin and rescue, and print Batch.find(id).fee_paise to confirm that the database still holds 1500000.

When both methods behave, the self-check goes back over every section:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 For the Batch class of the example, no attribute names appear in Ruby code. Where does Active Record read them from?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: From the batches table, which it inspects when the model first needs it

    One class per table, and the table decides the attributes. Add a column with a migration, and the model has it the next time it is loaded.

  2. Question 2 of 10 A form posts params[:seats] = "22" and the controller runs batch.seats = params[:seats]. What is batch.seats right after that line, before any save?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: The Integer 22

    Columns are typed, and assignment casts to the column's type. Values from the request arrive as strings; the model turns them into usable types the moment they are assigned.

  3. Question 3 of 10 A background job reads a webhook payload and looks up a Batch by its token. If no batch has that token, the job should log and move on. Which method fits?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: Batch.find_by(token: payload[:token])

    find raises; find_by returns nil. A missing row that the caller handles is find_by's shape.

  4. Question 4 of 10 Batch.create!(name: "C") runs, then Batch.create!(name: "A") runs, then Batch.create!(name: "B") runs on an empty table. What does Batch.first.name return?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: "C"

    first and last order by the primary key when no order is given. Add .order(:name) when you need the first row by a different column.

  5. Question 5 of 10 A background job calls batch.save on an invalid record. What is the next thing the job sees?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: save returns false and the job continues

    save returns false on an invalid record. In a job, either check the return value or call save!, so a failure rolls back the transaction instead of being silently ignored.

  6. Question 6 of 10 batch.assign_attributes(fee_paise: 16_00_000) is followed by batch.save, which succeeds. What SQL did these two lines run?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: Zero queries from assign_attributes, one UPDATE from save

    update(attrs) is a shortcut for assign_attributes(attrs) + save. Each helper runs the SQL you would expect: no SQL on assignment, one UPDATE on save.

  7. Question 7 of 10 A cache keyed on batch.updated_at serves the batch page. A one-off repair runs Batch.find(7).update_columns(name: "Class 10 Science (10A)"). What does the cache show after the repair?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: The old name, because updated_at did not change and the cache key is the same

    updated_at is often a cache key, so a method that keeps it as it was may leave stale pages on screen until the cache expires or is purged.

  8. Question 8 of 10 Batch has an after_update callback that posts a message to a webhook on every change. A migration runs Batch.where(seats: nil).update_all(seats: 30). How many webhook messages are posted?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: Zero

    update_all is one UPDATE on the database. For callbacks to run on each row, load the rows (find_each) and call update or save on each; one webhook message per row then costs N queries plus N posts.

  9. Question 9 of 10 A Batch has no associations yet. What does batch.destroy do, in SQL?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: One DELETE FROM batches WHERE id = <this id>

    destroy is a real DELETE, with the callbacks and dependent: rules of the model wrapping it. The example printed exactly one DELETE for a batch with no associations.

  10. Question 10 of 10 Batch.where(archived: true).destroy_all runs over 1,000 archived batches that have no associations. How many SELECT queries does it run to load the rows before the DELETEs?

    Type a number.

    Show the answer: Question 10 of 10

    Answer: 1 queries

    destroy_all loads every matching row with one SELECT and calls destroy on each, so 1,000 rows cost one SELECT and 1,000 DELETEs. delete_all is one DELETE in total, with no row loaded. Pick destroy_all when callbacks or dependent: rules must run; pick delete_all for a plain mass removal.

Interview questions

Warm-up: how do save and save! differ, and which case does each fit?

Both save the record if it is valid, run the same before_save and after_save callbacks, and touch updated_at. They differ in what happens on an invalid record. save returns false and fills errors.full_messages, so a controller can render the form again with the messages; the surrounding transaction continues. save! raises ActiveRecord::RecordInvalid, which rolls back the transaction, so a job or a script stops at the first bad row instead of carrying on with a half-finished change. Use save in a controller that reads from a form (the view reads the errors), and save! in a background job, a seed, a migration or an inner method that must not swallow a failure. The same pair exists for update and update!, and for create and create!, with the same meaning.

Two questions in the module bank build on this lesson. Decide your own answer first, then open each one:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A controller receives a batch fee from an admin's form. A nightly job normalises every batch (trims whitespace in name, rebuilds a derived field). Pick the persistence method for each case, and defend each choice against the four methods you did not pick.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A Batch has_many :students, dependent: :destroy. A developer writes batch.delete to make an admin page faster. What does the shortcut do that batch.destroy would not, and how do you make sure the shortcut never runs in production?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. A form sends "22" for the integer column seats. What does the model hold after assignment?

    The Integer 22. Active Record reads the column types from the database and casts each value the moment it is assigned to the attribute.

  2. What do find, find_by and where each give back when nothing matches?

    find raises ActiveRecord::RecordNotFound, which Rails turns into a 404. find_by returns nil. where returns an empty, lazy relation that sends no SQL until something asks for its rows.

  3. How do save and save! behave on an invalid record, and where does each belong?

    save returns false and fills errors, so a controller can show the form again with the messages. save! raises ActiveRecord::RecordInvalid, so a job, a seed or a script stops at the first bad row. Both run validations and callbacks and touch updated_at.

  4. What do update_columns and update_all leave out, and when is that acceptable?

    They skip validations and callbacks and leave updated_at untouched. Use them only on data your own code controls, never on values that came from a user.

  5. How does destroy differ from delete on a record?

    destroy loads the row first and runs callbacks and the dependent: rules of its associations. delete sends one DELETE with none of that, so it suits only a row with no associations and nothing to clean up.

  6. Which methods tell you what changed on a record before a save, and which after it?

    Before the save, changed? and changes. After it, saved_change_to_<attribute>? and previous_changes.

Key takeaways

  • Active Record reads a table’s columns from the database and gives the model a typed getter and setter for each one, so science.seats = "22" becomes the Integer 22 the moment it is assigned.
  • find raises ActiveRecord::RecordNotFound (which Rails turns into a 404), find_by returns nil, and where returns a lazy Relation that runs no SQL until a method asks for its rows.
  • save, save! and update run validations and callbacks and touch updated_at; save returns false on an invalid record and save! raises ActiveRecord::RecordInvalid.
  • update_columns and update_all skip validations and callbacks and keep updated_at as it was, so they belong on data your own code already owns, never on input from a user.
  • destroy loads the row and runs callbacks and dependent: rules; delete issues one DELETE with none of that, so it only fits a row with no associations and no bookkeeping to run.
  • Dirty tracking tells code what moved: changed? and changes before a save, saved_change_to_*? and previous_changes after one.

Terms in this lesson

Active Record
Rails' object-relational mapper: one Ruby class holds the rules, queries and lifecycle of one database table, and the table's columns become typed attributes of the class.
attribute
A getter and setter on an Active Record model that reads and writes one column of its table, with the column's type and the model's rules around it.
CRUD
The four basic data operations on a record: create (insert a row), read (fetch rows), update (change columns of a row) and delete (remove a row).
dirty tracking
Active Record's record of which attributes a model has changed since it was loaded or last saved, read with changed?, changes, saved_change_to_<name>? and previous_changes.
type casting
Converting a value to the type a column expects when it is assigned to an attribute, so a string from a request becomes the Integer, Date or Boolean the schema holds.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.